fix: implement persistent SSH key storage with key generation

- Fix 'error in libcrypto' issue by using persistent key files instead of temporary ones
- Add SSH key pair generation feature with 'Generate Key Pair' button
- Add 'View Public Key' button for generated keys with copy-to-clipboard functionality
- Remove confusing 'both' authentication option, now only supports 'password' OR 'key'
- Add persistent storage in data/ssh-keys/ directory with proper permissions
- Update database schema with ssh_key_path and key_generated columns
- Add API endpoints for key generation and public key retrieval
- Enhance UX by hiding manual key input when key pair is generated
- Update HelpModal documentation to reflect new SSH key features
- Fix all TypeScript compilation errors and linting issues

Resolves SSH authentication failures during script execution
This commit is contained in:
Michel Roegl-Brunner
2025-10-16 15:42:26 +02:00
parent 0e95c125d3
commit 94e97a7366
16 changed files with 874 additions and 271 deletions

View File

@@ -0,0 +1,110 @@
<!-- e1958379-99ce-42d2-8fe6-2c5007b3f52a cd8f1eb6-6ae5-4b21-9ca9-c208d4e80622 -->
# Persistent SSH Keys with Simplified Authentication
## Overview
Simplify SSH authentication to only support password OR key (remove confusing 'both' option). Use persistent key files instead of temporary files. Add on-the-fly key generation with public key viewing/copying.
## Implementation Steps
### 1. Create SSH Keys Directory Structure
- Add `data/ssh-keys/` directory for persistent SSH key files
- Name format: `server_{id}_key` and `server_{id}_key.pub`
- Set permissions: 0700 for directory, 0600 for key files
- Add to `.gitignore` to prevent committing keys
### 2. Update Database Schema (`src/server/database.js`)
- Change auth_type CHECK constraint: only allow 'password' or 'key' (remove 'both')
- Add `ssh_key_path` TEXT column for file path
- Add `key_generated` INTEGER (0/1) to track generated vs user-provided keys
- Migration: Convert existing 'both' auth_type to 'key'
- Update `addServer()`: Write key to persistent file, store path
- Update `updateServer()`: Handle key changes (write new, delete old)
- Update `deleteServer()`: Clean up key files
### 3. SSH Key Generation Feature (`src/server/ssh-service.js`)
- Add `generateKeyPair(serverId)` method using `ssh-keygen` command
- Command: `ssh-keygen -t ed25519 -f data/ssh-keys/server_{id}_key -N "" -C "pve-scripts-local"`
- Return both private and public key content
- Add `getPublicKey(keyPath)` to extract public key from private key
### 4. Backend API Endpoints (New Files)
- `POST /api/servers/generate-keypair`
- Generate temporary key pair (not yet saved to server)
- Return `{ privateKey, publicKey }`
- `GET /api/servers/[id]/public-key`
- Only if `key_generated === 1`
- Return `{ publicKey, serverName, serverIp }`
### 5. Frontend: ServerForm Component
- **Remove 'both' from auth_type dropdown** - only show Password/SSH Key
- Add "Generate Key Pair" button (visible when auth_type === 'key')
- On generate: populate SSH key field, show modal with public key
- Add PublicKeyModal component with copy-to-clipboard
- Disable manual key entry when using generated key
### 6. Frontend: ServerList Component
- Add "View Public Key" button between Test Connection and Edit
- Only show when `server.key_generated === true`
- Click opens PublicKeyModal with copy button
- Show instructions: "Add this to /root/.ssh/authorized_keys on your server"
### 7. Update SSH Service (`src/server/ssh-service.js`)
- **Remove all 'both' auth_type handling**
- Remove temp file creation from `testWithSSHKey()`
- Use persistent `ssh_key_path` from database
- Simplify `testConnection()`: only handle 'password' or 'key'
### 8. Update SSH Execution Service (`src/server/ssh-execution-service.js`)
- **Remove `createTempKeyFile()` method**
- **Remove all 'both' auth_type cases**
- Update `buildSSHCommand()`: use `ssh_key_path`, only handle 'password'/'key'
- Update `transferScriptsFolder()`: use `ssh_key_path` in rsync
- Update `executeCommand()`: use `ssh_key_path`
- Remove all temp file cleanup code
### 9. Files to Create/Modify
- `src/server/database.js` - Schema changes, persistence
- `src/server/ssh-service.js` - Key generation, remove temp files, remove 'both'
- `src/server/ssh-execution-service.js` - Use persistent keys, remove 'both'
- `src/app/api/servers/generate-keypair/route.ts` - NEW
- `src/app/api/servers/[id]/public-key/route.ts` - NEW
- `src/app/_components/ServerForm.tsx` - Generate button, remove 'both'
- `src/app/_components/ServerList.tsx` - View public key button
- `src/app/_components/PublicKeyModal.tsx` - NEW component
- `.gitignore` - Add `data/ssh-keys/`
### 11. Migration & Initialization
- On startup: create `data/ssh-keys/` if missing
- Existing 'both' servers: convert to 'key' auth_type
- Existing ssh_key content: migrate to persistent files on first use
- Set `key_generated = 0` for migrated servers
## Benefits
- Simpler auth model (no confusing 'both' option)
- Fixes "error in libcrypto" timing issues
- User-friendly key generation
- Easy public key access for setup
- Less code complexity
### To-dos
- [ ] Create data/ssh-keys directory structure and update .gitignore
- [ ] Add ssh_key_path column to database and implement migration
- [ ] Modify addServer/updateServer/deleteServer to handle persistent key files
- [ ] Remove temp key creation from ssh-service.js and use persistent paths
- [ ] Remove createTempKeyFile and update all methods to use persistent keys
- [ ] Test with existing servers that have SSH keys to ensure migration works
- [ ] Modify the HelpModal to reflect the changes Made to the SSH auth
- [ ] Create a fix branch

3
.gitignore vendored
View File

@@ -16,6 +16,9 @@
db.sqlite db.sqlite
data/settings.db data/settings.db
# ssh keys (sensitive)
data/ssh-keys/
# next.js # next.js
/.next/ /.next/
/out/ /out/

43
scripts/ct/debian.sh Normal file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
SCRIPT_DIR="$(dirname "$0")"
source "$SCRIPT_DIR/../core/build.func"
# Copyright (c) 2021-2025 tteck
# Author: tteck (tteckster)
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://www.debian.org/
APP="Debian"
var_tags="${var_tags:-os}"
var_cpu="${var_cpu:-1}"
var_ram="${var_ram:-512}"
var_disk="${var_disk:-2}"
var_os="${var_os:-debian}"
var_version="${var_version:-13}"
var_unprivileged="${var_unprivileged:-1}"
header_info "$APP"
variables
color
catch_errors
function update_script() {
header_info
check_container_storage
check_container_resources
if [[ ! -d /var ]]; then
msg_error "No ${APP} Installation Found!"
exit
fi
msg_info "Updating $APP LXC"
$STD apt update
$STD apt -y upgrade
msg_ok "Updated $APP LXC"
exit
}
start
build_container
description
msg_ok "Completed Successfully!\n"
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Copyright (c) 2021-2025 tteck
# Author: tteck (tteckster)
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://www.debian.org/
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
color
verb_ip6
catch_errors
setting_up_container
network_check
update_os
motd_ssh
customize
msg_info "Cleaning up"
$STD apt -y autoremove
$STD apt -y autoclean
$STD apt -y clean
msg_ok "Cleaned"

View File

@@ -55,8 +55,15 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
<ul className="text-sm text-muted-foreground space-y-2"> <ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Password:</strong> Use username and password authentication</li> <li> <strong>Password:</strong> Use username and password authentication</li>
<li> <strong>SSH Key:</strong> Use SSH key pair for secure authentication</li> <li> <strong>SSH Key:</strong> Use SSH key pair for secure authentication</li>
<li> <strong>Both:</strong> Try SSH key first, fallback to password if needed</li>
</ul> </ul>
<div className="mt-3 p-3 bg-blue-50 dark:bg-blue-950/20 rounded-md">
<h5 className="font-medium text-blue-900 dark:text-blue-100 mb-2">SSH Key Features:</h5>
<ul className="text-xs text-blue-800 dark:text-blue-200 space-y-1">
<li> <strong>Generate Key Pair:</strong> Create new SSH keys automatically</li>
<li> <strong>View Public Key:</strong> Copy public key for server setup</li>
<li> <strong>Persistent Storage:</strong> Keys are stored securely on disk</li>
</ul>
</div>
</div> </div>
<div className="p-4 border border-border rounded-lg"> <div className="p-4 border border-border rounded-lg">

View File

@@ -0,0 +1,147 @@
'use client';
import { useState } from 'react';
import { X, Copy, Check, Server, Globe } from 'lucide-react';
import { Button } from './ui/button';
interface PublicKeyModalProps {
isOpen: boolean;
onClose: () => void;
publicKey: string;
serverName: string;
serverIp: string;
}
export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverIp }: PublicKeyModalProps) {
const [copied, setCopied] = useState(false);
if (!isOpen) return null;
const handleCopy = async () => {
try {
// Try modern clipboard API first
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(publicKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
// Fallback for older browsers or non-HTTPS
const textArea = document.createElement('textarea');
textArea.value = publicKey;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (fallbackError) {
console.error('Fallback copy failed:', fallbackError);
// If all else fails, show the key in an alert
alert('Please manually copy this key:\n\n' + publicKey);
}
document.body.removeChild(textArea);
}
} catch (error) {
console.error('Failed to copy to clipboard:', error);
// Fallback: show the key in an alert
alert('Please manually copy this key:\n\n' + publicKey);
}
};
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<Server className="h-6 w-6 text-blue-600" />
</div>
<div>
<h2 className="text-xl font-semibold text-card-foreground">SSH Public Key</h2>
<p className="text-sm text-muted-foreground">Add this key to your server&apos;s authorized_keys</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Server Info */}
<div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Server className="h-4 w-4" />
<span className="font-medium">{serverName}</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Globe className="h-4 w-4" />
<span>{serverIp}</span>
</div>
</div>
{/* Instructions */}
<div className="space-y-2">
<h3 className="font-medium text-foreground">Instructions:</h3>
<ol className="text-sm text-muted-foreground space-y-1 list-decimal list-inside">
<li>Copy the public key below</li>
<li>SSH into your server: <code className="bg-muted px-1 rounded">ssh root@{serverIp}</code></li>
<li>Add the key to authorized_keys: <code className="bg-muted px-1 rounded">echo &quot;&lt;paste-key&gt;&quot; &gt;&gt; ~/.ssh/authorized_keys</code></li>
<li>Set proper permissions: <code className="bg-muted px-1 rounded">chmod 600 ~/.ssh/authorized_keys</code></li>
</ol>
</div>
{/* Public Key */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">Public Key:</label>
<Button
variant="outline"
size="sm"
onClick={handleCopy}
className="gap-2"
>
{copied ? (
<>
<Check className="h-4 w-4" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy
</>
)}
</Button>
</div>
<textarea
value={publicKey}
readOnly
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground font-mono text-xs min-h-[120px] resize-none border-border focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
placeholder="Public key will appear here..."
/>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 pt-4 border-t border-border">
<Button variant="outline" onClick={onClose}>
Close
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -4,6 +4,8 @@ import { useState, useEffect } from 'react';
import type { CreateServerData } from '../../types/server'; import type { CreateServerData } from '../../types/server';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { SSHKeyInput } from './SSHKeyInput'; import { SSHKeyInput } from './SSHKeyInput';
import { PublicKeyModal } from './PublicKeyModal';
import { Key } from 'lucide-react';
interface ServerFormProps { interface ServerFormProps {
onSubmit: (data: CreateServerData) => void; onSubmit: (data: CreateServerData) => void;
@@ -30,6 +32,11 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
const [errors, setErrors] = useState<Partial<Record<keyof CreateServerData, string>>>({}); const [errors, setErrors] = useState<Partial<Record<keyof CreateServerData, string>>>({});
const [sshKeyError, setSshKeyError] = useState<string>(''); const [sshKeyError, setSshKeyError] = useState<string>('');
const [colorCodingEnabled, setColorCodingEnabled] = useState(false); const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
const [isGeneratingKey, setIsGeneratingKey] = useState(false);
const [showPublicKeyModal, setShowPublicKeyModal] = useState(false);
const [generatedPublicKey, setGeneratedPublicKey] = useState('');
const [, setIsGeneratedKey] = useState(false);
const [, setGeneratedServerId] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
const loadColorCodingSetting = async () => { const loadColorCodingSetting = async () => {
@@ -75,25 +82,18 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
// Validate authentication based on auth_type // Validate authentication based on auth_type
const authType = formData.auth_type ?? 'password'; const authType = formData.auth_type ?? 'password';
if (authType === 'password' || authType === 'both') { if (authType === 'password') {
if (!formData.password?.trim()) { if (!formData.password?.trim()) {
newErrors.password = 'Password is required for password authentication'; newErrors.password = 'Password is required for password authentication';
} }
} }
if (authType === 'key' || authType === 'both') { if (authType === 'key') {
if (!formData.ssh_key?.trim()) { if (!formData.ssh_key?.trim()) {
newErrors.ssh_key = 'SSH key is required for key authentication'; newErrors.ssh_key = 'SSH key is required for key authentication';
} }
} }
// Check if at least one authentication method is provided
if (authType === 'both') {
if (!formData.password?.trim() && !formData.ssh_key?.trim()) {
newErrors.password = 'At least one authentication method (password or SSH key) is required';
newErrors.ssh_key = 'At least one authentication method (password or SSH key) is required';
}
}
setErrors(newErrors); setErrors(newErrors);
return Object.keys(newErrors).length === 0 && !sshKeyError; return Object.keys(newErrors).length === 0 && !sshKeyError;
@@ -127,6 +127,54 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
if (errors[field]) { if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: undefined })); setErrors(prev => ({ ...prev, [field]: undefined }));
} }
// Reset generated key state when switching auth types
if (field === 'auth_type') {
setIsGeneratedKey(false);
setGeneratedPublicKey('');
}
};
const handleGenerateKeyPair = async () => {
setIsGeneratingKey(true);
try {
const response = await fetch('/api/servers/generate-keypair', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Failed to generate key pair');
}
const data = await response.json() as { success: boolean; privateKey?: string; publicKey?: string; serverId?: number; error?: string };
if (data.success) {
const serverId = data.serverId ?? 0;
const keyPath = `data/ssh-keys/server_${serverId}_key`;
setFormData(prev => ({
...prev,
ssh_key: data.privateKey ?? '',
ssh_key_path: keyPath,
key_generated: 1
}));
setGeneratedPublicKey(data.publicKey ?? '');
setGeneratedServerId(serverId);
setIsGeneratedKey(true);
setShowPublicKeyModal(true);
setSshKeyError('');
} else {
throw new Error(data.error ?? 'Failed to generate key pair');
}
} catch (error) {
console.error('Error generating key pair:', error);
setSshKeyError(error instanceof Error ? error.message : 'Failed to generate key pair');
} finally {
setIsGeneratingKey(false);
}
}; };
const handleSSHKeyChange = (value: string) => { const handleSSHKeyChange = (value: string) => {
@@ -137,6 +185,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
}; };
return ( return (
<>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
@@ -221,7 +270,6 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
> >
<option value="password">Password Only</option> <option value="password">Password Only</option>
<option value="key">SSH Key Only</option> <option value="key">SSH Key Only</option>
<option value="both">Both Password & SSH Key</option>
</select> </select>
</div> </div>
@@ -247,10 +295,10 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
</div> </div>
{/* Password Authentication */} {/* Password Authentication */}
{(formData.auth_type === 'password' || formData.auth_type === 'both') && ( {formData.auth_type === 'password' && (
<div> <div>
<label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1"> <label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
Password {formData.auth_type === 'both' ? '(Optional)' : '*'} Password *
</label> </label>
<input <input
type="password" type="password"
@@ -267,19 +315,55 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
)} )}
{/* SSH Key Authentication */} {/* SSH Key Authentication */}
{(formData.auth_type === 'key' || formData.auth_type === 'both') && ( {formData.auth_type === 'key' && (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-muted-foreground mb-1"> <div className="flex items-center justify-between mb-1">
SSH Private Key {formData.auth_type === 'both' ? '(Optional)' : '*'} <label className="block text-sm font-medium text-muted-foreground">
</label> SSH Private Key *
<SSHKeyInput </label>
value={formData.ssh_key ?? ''} <Button
onChange={handleSSHKeyChange} type="button"
onError={setSshKeyError} variant="outline"
/> size="sm"
{errors.ssh_key && <p className="mt-1 text-sm text-destructive">{errors.ssh_key}</p>} onClick={handleGenerateKeyPair}
{sshKeyError && <p className="mt-1 text-sm text-destructive">{sshKeyError}</p>} disabled={isGeneratingKey}
className="gap-2"
>
<Key className="h-4 w-4" />
{isGeneratingKey ? 'Generating...' : 'Generate Key Pair'}
</Button>
</div>
{/* Show manual key input only if no key has been generated */}
{!formData.key_generated && (
<>
<SSHKeyInput
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>}
</>
)}
{/* Show generated key status */}
{formData.key_generated && (
<div className="p-3 bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800 rounded-md">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm font-medium text-green-800 dark:text-green-200">
SSH key pair generated successfully
</span>
</div>
<p className="text-xs text-green-700 dark:text-green-300 mt-1">
The private key has been generated and will be saved with the server.
</p>
</div>
)}
</div> </div>
<div> <div>
@@ -323,6 +407,16 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
</Button> </Button>
</div> </div>
</form> </form>
{/* Public Key Modal */}
<PublicKeyModal
isOpen={showPublicKeyModal}
onClose={() => setShowPublicKeyModal(false)}
publicKey={generatedPublicKey}
serverName={formData.name || 'New Server'}
serverIp={formData.ip}
/>
</>
); );
} }

View File

@@ -5,6 +5,8 @@ import type { Server, CreateServerData } from '../../types/server';
import { ServerForm } from './ServerForm'; import { ServerForm } from './ServerForm';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { ConfirmationModal } from './ConfirmationModal'; import { ConfirmationModal } from './ConfirmationModal';
import { PublicKeyModal } from './PublicKeyModal';
import { Key } from 'lucide-react';
interface ServerListProps { interface ServerListProps {
servers: Server[]; servers: Server[];
@@ -24,6 +26,12 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
confirmText: string; confirmText: string;
onConfirm: () => void; onConfirm: () => void;
} | null>(null); } | null>(null);
const [showPublicKeyModal, setShowPublicKeyModal] = useState(false);
const [publicKeyData, setPublicKeyData] = useState<{
publicKey: string;
serverName: string;
serverIp: string;
} | null>(null);
const handleEdit = (server: Server) => { const handleEdit = (server: Server) => {
setEditingId(server.id); setEditingId(server.id);
@@ -40,6 +48,32 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
setEditingId(null); setEditingId(null);
}; };
const handleViewPublicKey = async (server: Server) => {
try {
const response = await fetch(`/api/servers/${server.id}/public-key`);
if (!response.ok) {
throw new Error('Failed to retrieve public key');
}
const data = await response.json() as { success: boolean; publicKey?: string; serverName?: string; serverIp?: string; error?: string };
if (data.success) {
setPublicKeyData({
publicKey: data.publicKey ?? '',
serverName: data.serverName ?? '',
serverIp: data.serverIp ?? ''
});
setShowPublicKeyModal(true);
} else {
throw new Error(data.error ?? 'Failed to retrieve public key');
}
} catch (error) {
console.error('Error retrieving public key:', error);
// You could show a toast notification here
}
};
const handleDelete = (id: number) => { const handleDelete = (id: number) => {
const server = servers.find(s => s.id === id); const server = servers.find(s => s.id === id);
if (!server) return; if (!server) return;
@@ -218,6 +252,19 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
)} )}
</Button> </Button>
<div className="flex space-x-2"> <div className="flex space-x-2">
{/* View Public Key button - only show for generated keys */}
{server.key_generated === 1 && (
<Button
onClick={() => handleViewPublicKey(server)}
variant="outline"
size="sm"
className="flex-1 sm:flex-none border-blue-500/20 text-blue-400 bg-blue-500/10 hover:bg-blue-500/20"
>
<Key className="w-4 h-4 mr-1" />
<span className="hidden sm:inline">View Public Key</span>
<span className="sm:hidden">Key</span>
</Button>
)}
<Button <Button
onClick={() => handleEdit(server)} onClick={() => handleEdit(server)}
variant="outline" variant="outline"
@@ -263,6 +310,20 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
cancelButtonText="Cancel" cancelButtonText="Cancel"
/> />
)} )}
{/* Public Key Modal */}
{publicKeyData && (
<PublicKeyModal
isOpen={showPublicKeyModal}
onClose={() => {
setShowPublicKeyModal(false);
setPublicKeyData(null);
}}
publicKey={publicKeyData.publicKey}
serverName={publicKeyData.serverName}
serverIp={publicKeyData.serverIp}
/>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,64 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getDatabase } from '../../../../../server/database';
import { getSSHService } from '../../../../../server/ssh-service';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: idParam } = await params;
const id = parseInt(idParam);
if (isNaN(id)) {
return NextResponse.json(
{ error: 'Invalid server ID' },
{ status: 400 }
);
}
const db = getDatabase();
const server = db.getServerById(id);
if (!server) {
return NextResponse.json(
{ error: 'Server not found' },
{ status: 404 }
);
}
// Only allow viewing public key if it was generated by the system
if (!(server as any).key_generated) {
return NextResponse.json(
{ error: 'Public key not available for user-provided keys' },
{ status: 403 }
);
}
if (!(server as any).ssh_key_path) {
return NextResponse.json(
{ error: 'SSH key path not found' },
{ status: 404 }
);
}
const sshService = getSSHService();
const publicKey = sshService.getPublicKey((server as any).ssh_key_path as string);
return NextResponse.json({
success: true,
publicKey,
serverName: (server as any).name,
serverIp: (server as any).ip
});
} catch (error) {
console.error('Error retrieving public key:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : String(error)
},
{ status: 500 }
);
}
}

View File

@@ -52,7 +52,7 @@ export async function PUT(
} }
const body = await request.json(); const body = await request.json();
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color }: CreateServerData = body; const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated, ssh_key_path }: CreateServerData = body;
// Validate required fields // Validate required fields
if (!name || !ip || !user) { if (!name || !ip || !user) {
@@ -73,7 +73,7 @@ export async function PUT(
// Validate authentication based on auth_type // Validate authentication based on auth_type
const authType = auth_type ?? 'password'; const authType = auth_type ?? 'password';
if (authType === 'password' || authType === 'both') { if (authType === 'password') {
if (!password?.trim()) { if (!password?.trim()) {
return NextResponse.json( return NextResponse.json(
{ error: 'Password is required for password authentication' }, { error: 'Password is required for password authentication' },
@@ -82,7 +82,7 @@ export async function PUT(
} }
} }
if (authType === 'key' || authType === 'both') { if (authType === 'key') {
if (!ssh_key?.trim()) { if (!ssh_key?.trim()) {
return NextResponse.json( return NextResponse.json(
{ error: 'SSH key is required for key authentication' }, { error: 'SSH key is required for key authentication' },
@@ -91,15 +91,6 @@ export async function PUT(
} }
} }
// Check if at least one authentication method is provided
if (authType === 'both') {
if (!password?.trim() && !ssh_key?.trim()) {
return NextResponse.json(
{ error: 'At least one authentication method (password or SSH key) is required' },
{ status: 400 }
);
}
}
const db = getDatabase(); const db = getDatabase();
@@ -121,7 +112,9 @@ export async function PUT(
ssh_key, ssh_key,
ssh_key_passphrase, ssh_key_passphrase,
ssh_port: ssh_port ?? 22, ssh_port: ssh_port ?? 22,
color color,
key_generated: key_generated ?? 0,
ssh_key_path
}); });
return NextResponse.json( return NextResponse.json(

View File

@@ -0,0 +1,32 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getSSHService } from '../../../../server/ssh-service';
import { getDatabase } from '../../../../server/database';
export async function POST(_request: NextRequest) {
try {
const sshService = getSSHService();
const db = getDatabase();
// Get the next available server ID for key file naming
const serverId = db.getNextServerId();
const keyPair = await sshService.generateKeyPair(serverId);
return NextResponse.json({
success: true,
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey,
serverId: serverId
});
} catch (error) {
console.error('Error generating SSH key pair:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to generate SSH key pair'
},
{ status: 500 }
);
}
}

View File

@@ -20,7 +20,7 @@ export async function GET() {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); const body = await request.json();
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color }: CreateServerData = body; const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated, ssh_key_path }: CreateServerData = body;
// Validate required fields // Validate required fields
if (!name || !ip || !user) { if (!name || !ip || !user) {
@@ -41,7 +41,7 @@ export async function POST(request: NextRequest) {
// Validate authentication based on auth_type // Validate authentication based on auth_type
const authType = auth_type ?? 'password'; const authType = auth_type ?? 'password';
if (authType === 'password' || authType === 'both') { if (authType === 'password') {
if (!password?.trim()) { if (!password?.trim()) {
return NextResponse.json( return NextResponse.json(
{ error: 'Password is required for password authentication' }, { error: 'Password is required for password authentication' },
@@ -50,7 +50,7 @@ export async function POST(request: NextRequest) {
} }
} }
if (authType === 'key' || authType === 'both') { if (authType === 'key') {
if (!ssh_key?.trim()) { if (!ssh_key?.trim()) {
return NextResponse.json( return NextResponse.json(
{ error: 'SSH key is required for key authentication' }, { error: 'SSH key is required for key authentication' },
@@ -59,15 +59,6 @@ export async function POST(request: NextRequest) {
} }
} }
// Check if at least one authentication method is provided
if (authType === 'both') {
if (!password?.trim() && !ssh_key?.trim()) {
return NextResponse.json(
{ error: 'At least one authentication method (password or SSH key) is required' },
{ status: 400 }
);
}
}
const db = getDatabase(); const db = getDatabase();
const result = db.createServer({ const result = db.createServer({
@@ -79,7 +70,9 @@ export async function POST(request: NextRequest) {
ssh_key, ssh_key,
ssh_key_passphrase, ssh_key_passphrase,
ssh_port: ssh_port ?? 22, ssh_port: ssh_port ?? 22,
color color,
key_generated: key_generated ?? 0,
ssh_key_path
}); });
return NextResponse.json( return NextResponse.json(

View File

@@ -1,5 +1,7 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { join } from 'path'; import { join } from 'path';
import { writeFileSync, unlinkSync, chmodSync, mkdirSync } from 'fs';
import { existsSync } from 'fs';
class DatabaseService { class DatabaseService {
constructor() { constructor() {
@@ -9,6 +11,12 @@ class DatabaseService {
} }
init() { init() {
// Ensure data/ssh-keys directory exists
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
if (!existsSync(sshKeysDir)) {
mkdirSync(sshKeysDir, { mode: 0o700 });
}
// Create servers table if it doesn't exist // Create servers table if it doesn't exist
this.db.exec(` this.db.exec(`
CREATE TABLE IF NOT EXISTS servers ( CREATE TABLE IF NOT EXISTS servers (
@@ -17,10 +25,12 @@ class DatabaseService {
ip TEXT NOT NULL, ip TEXT NOT NULL,
user TEXT NOT NULL, user TEXT NOT NULL,
password TEXT, password TEXT,
auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key', 'both')), auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key')),
ssh_key TEXT, ssh_key TEXT,
ssh_key_passphrase TEXT, ssh_key_passphrase TEXT,
ssh_port INTEGER DEFAULT 22, ssh_port INTEGER DEFAULT 22,
ssh_key_path TEXT,
key_generated INTEGER DEFAULT 0,
color TEXT, color TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
@@ -30,7 +40,7 @@ class DatabaseService {
// Migration: Add new columns to existing servers table // Migration: Add new columns to existing servers table
try { try {
this.db.exec(` this.db.exec(`
ALTER TABLE servers ADD COLUMN auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key', 'both')) ALTER TABLE servers ADD COLUMN auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key'))
`); `);
} catch (e) { } catch (e) {
// Column already exists, ignore error // Column already exists, ignore error
@@ -68,6 +78,22 @@ class DatabaseService {
// Column already exists, ignore error // Column already exists, ignore error
} }
try {
this.db.exec(`
ALTER TABLE servers ADD COLUMN ssh_key_path TEXT
`);
} catch (e) {
// Column already exists, ignore error
}
try {
this.db.exec(`
ALTER TABLE servers ADD COLUMN key_generated INTEGER DEFAULT 0
`);
} catch (e) {
// Column already exists, ignore error
}
// Update existing servers to have auth_type='password' if not set // Update existing servers to have auth_type='password' if not set
this.db.exec(` this.db.exec(`
UPDATE servers SET auth_type = 'password' WHERE auth_type IS NULL UPDATE servers SET auth_type = 'password' WHERE auth_type IS NULL
@@ -78,6 +104,16 @@ class DatabaseService {
UPDATE servers SET ssh_port = 22 WHERE ssh_port IS NULL UPDATE servers SET ssh_port = 22 WHERE ssh_port IS NULL
`); `);
// Migration: Convert 'both' auth_type to 'key'
this.db.exec(`
UPDATE servers SET auth_type = 'key' WHERE auth_type = 'both'
`);
// Update existing servers to have key_generated=0 if not set
this.db.exec(`
UPDATE servers SET key_generated = 0 WHERE key_generated IS NULL
`);
// Migration: Add web_ui_ip column to existing installed_scripts table // Migration: Add web_ui_ip column to existing installed_scripts table
try { try {
this.db.exec(` this.db.exec(`
@@ -129,12 +165,21 @@ class DatabaseService {
* @param {import('../types/server').CreateServerData} serverData * @param {import('../types/server').CreateServerData} serverData
*/ */
createServer(serverData) { createServer(serverData) {
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color } = serverData; const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
let ssh_key_path = null;
// If using SSH key authentication, create persistent key file
if (auth_type === 'key' && ssh_key) {
const serverId = this.getNextServerId();
ssh_key_path = this.createSSHKeyFile(serverId, ssh_key);
}
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
INSERT INTO servers (name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color) INSERT INTO servers (name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, ssh_key_path, key_generated, color)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, color); return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, ssh_key_path, key_generated || 0, color);
} }
getAllServers() { getAllServers() {
@@ -155,19 +200,85 @@ class DatabaseService {
* @param {import('../types/server').CreateServerData} serverData * @param {import('../types/server').CreateServerData} serverData
*/ */
updateServer(id, serverData) { updateServer(id, serverData) {
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color } = serverData; const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
// Get existing server to check for key changes
const existingServer = this.getServerById(id);
// @ts-ignore - Database migration adds this column
let ssh_key_path = existingServer?.ssh_key_path;
// Handle SSH key changes
if (auth_type === 'key' && ssh_key) {
// Delete old key file if it exists
// @ts-ignore - Database migration adds this column
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
try {
// @ts-ignore - Database migration adds this column
unlinkSync(existingServer.ssh_key_path);
// Also delete public key file if it exists
// @ts-ignore - Database migration adds this column
const pubKeyPath = existingServer.ssh_key_path + '.pub';
if (existsSync(pubKeyPath)) {
unlinkSync(pubKeyPath);
}
} catch (error) {
console.warn('Failed to delete old SSH key file:', error);
}
}
// Create new key file
ssh_key_path = this.createSSHKeyFile(id, ssh_key);
} else if (auth_type !== 'key') {
// If switching away from key auth, delete key files
// @ts-ignore - Database migration adds this column
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
try {
// @ts-ignore - Database migration adds this column
unlinkSync(existingServer.ssh_key_path);
// @ts-ignore - Database migration adds this column
const pubKeyPath = existingServer.ssh_key_path + '.pub';
if (existsSync(pubKeyPath)) {
unlinkSync(pubKeyPath);
}
} catch (error) {
console.warn('Failed to delete SSH key file:', error);
}
}
ssh_key_path = null;
}
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
UPDATE servers UPDATE servers
SET name = ?, ip = ?, user = ?, password = ?, auth_type = ?, ssh_key = ?, ssh_key_passphrase = ?, ssh_port = ?, color = ? SET name = ?, ip = ?, user = ?, password = ?, auth_type = ?, ssh_key = ?, ssh_key_passphrase = ?, ssh_port = ?, ssh_key_path = ?, key_generated = ?, color = ?
WHERE id = ? WHERE id = ?
`); `);
return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, color, id); // @ts-ignore - Database migration adds this column
return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, ssh_key_path, key_generated !== undefined ? key_generated : (existingServer?.key_generated || 0), color, id);
} }
/** /**
* @param {number} id * @param {number} id
*/ */
deleteServer(id) { deleteServer(id) {
// Get server info before deletion to clean up key files
const server = this.getServerById(id);
// Delete SSH key files if they exist
// @ts-ignore - Database migration adds this column
if (server?.ssh_key_path && existsSync(server.ssh_key_path)) {
try {
// @ts-ignore - Database migration adds this column
unlinkSync(server.ssh_key_path);
// @ts-ignore - Database migration adds this column
const pubKeyPath = server.ssh_key_path + '.pub';
if (existsSync(pubKeyPath)) {
unlinkSync(pubKeyPath);
}
} catch (error) {
console.warn('Failed to delete SSH key file:', error);
}
}
const stmt = this.db.prepare('DELETE FROM servers WHERE id = ?'); const stmt = this.db.prepare('DELETE FROM servers WHERE id = ?');
return stmt.run(id); return stmt.run(id);
} }
@@ -316,6 +427,35 @@ class DatabaseService {
return stmt.run(server_id); return stmt.run(server_id);
} }
/**
* Get the next available server ID for key file naming
* @returns {number}
*/
getNextServerId() {
const stmt = this.db.prepare('SELECT MAX(id) as maxId FROM servers');
const result = stmt.get();
// @ts-ignore - SQL query result type
return (result?.maxId || 0) + 1;
}
/**
* Create SSH key file and return the path
* @param {number} serverId
* @param {string} sshKey
* @returns {string}
*/
createSSHKeyFile(serverId, sshKey) {
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
const normalizedKey = sshKey.trimEnd() + '\n';
writeFileSync(keyPath, normalizedKey);
chmodSync(keyPath, 0o600); // Set proper permissions
return keyPath;
}
close() { close() {
this.db.close(); this.db.close();
} }

View File

@@ -1,8 +1,6 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { spawn as ptySpawn } from 'node-pty'; import { spawn as ptySpawn } from 'node-pty';
import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync } from 'fs'; import { existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
/** /**
@@ -11,43 +9,22 @@ import { tmpdir } from 'os';
* @property {string} user - Username * @property {string} user - Username
* @property {string} [password] - Password (optional) * @property {string} [password] - Password (optional)
* @property {string} name - Server name * @property {string} name - Server name
* @property {string} [auth_type] - Authentication type ('password', 'key', 'both') * @property {string} [auth_type] - Authentication type ('password', 'key')
* @property {string} [ssh_key] - SSH private key content * @property {string} [ssh_key] - SSH private key content
* @property {string} [ssh_key_passphrase] - SSH key passphrase * @property {string} [ssh_key_passphrase] - SSH key passphrase
* @property {string} [ssh_key_path] - Path to persistent SSH key file
* @property {number} [ssh_port] - SSH port (default: 22) * @property {number} [ssh_port] - SSH port (default: 22)
*/ */
class SSHExecutionService { class SSHExecutionService {
/**
* Create a temporary SSH key file for authentication
* @param {Server} server - Server configuration
* @returns {string} Path to temporary key file
*/
createTempKeyFile(server) {
const { ssh_key } = server;
if (!ssh_key) {
throw new Error('SSH key not provided');
}
const tempDir = mkdtempSync(join(tmpdir(), 'ssh-key-'));
const tempKeyPath = join(tempDir, 'private_key');
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
const normalizedKey = ssh_key.trimEnd() + '\n';
writeFileSync(tempKeyPath, normalizedKey);
chmodSync(tempKeyPath, 0o600); // Set proper permissions
return tempKeyPath;
}
/** /**
* Build SSH command arguments based on authentication type * Build SSH command arguments based on authentication type
* @param {Server} server - Server configuration * @param {Server} server - Server configuration
* @param {string|null} [tempKeyPath=null] - Path to temporary key file (if using key auth)
* @returns {{command: string, args: string[]}} Command and arguments for SSH * @returns {{command: string, args: string[]}} Command and arguments for SSH
*/ */
buildSSHCommand(server, tempKeyPath = null) { buildSSHCommand(server) {
const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_port = 22 } = server; const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_key_path, ssh_port = 22 } = server;
const baseArgs = [ const baseArgs = [
'-t', '-t',
@@ -69,12 +46,14 @@ class SSHExecutionService {
if (auth_type === 'key') { if (auth_type === 'key') {
// SSH key authentication // SSH key authentication
if (tempKeyPath) { if (!ssh_key_path || !existsSync(ssh_key_path)) {
baseArgs.push('-i', tempKeyPath); throw new Error('SSH key file not found');
baseArgs.push('-o', 'PasswordAuthentication=no');
baseArgs.push('-o', 'PubkeyAuthentication=yes');
} }
baseArgs.push('-i', ssh_key_path);
baseArgs.push('-o', 'PasswordAuthentication=no');
baseArgs.push('-o', 'PubkeyAuthentication=yes');
if (ssh_key_passphrase) { if (ssh_key_passphrase) {
return { return {
command: 'sshpass', command: 'sshpass',
@@ -86,35 +65,6 @@ class SSHExecutionService {
args: [...baseArgs, `${user}@${ip}`] args: [...baseArgs, `${user}@${ip}`]
}; };
} }
} else if (auth_type === 'both') {
// Try SSH key first, then password
if (tempKeyPath) {
baseArgs.push('-i', tempKeyPath);
baseArgs.push('-o', 'PasswordAuthentication=yes');
baseArgs.push('-o', 'PubkeyAuthentication=yes');
if (ssh_key_passphrase) {
return {
command: 'sshpass',
args: ['-P', 'passphrase', '-p', ssh_key_passphrase, 'ssh', ...baseArgs, `${user}@${ip}`]
};
} else {
return {
command: 'ssh',
args: [...baseArgs, `${user}@${ip}`]
};
}
} else {
// Fallback to password
if (password) {
return {
command: 'sshpass',
args: ['-p', password, 'ssh', ...baseArgs, '-o', 'PasswordAuthentication=yes', '-o', 'PubkeyAuthentication=no', `${user}@${ip}`]
};
} else {
throw new Error('Password is required for password authentication');
}
}
} else { } else {
// Password authentication (default) // Password authentication (default)
if (password) { if (password) {
@@ -138,9 +88,6 @@ class SSHExecutionService {
* @returns {Promise<Object>} Process information * @returns {Promise<Object>} Process information
*/ */
async executeScript(server, scriptPath, onData, onError, onExit) { async executeScript(server, scriptPath, onData, onError, onExit) {
/** @type {string|null} */
let tempKeyPath = null;
try { try {
await this.transferScriptsFolder(server, onData, onError); await this.transferScriptsFolder(server, onData, onError);
@@ -148,13 +95,8 @@ class SSHExecutionService {
const relativeScriptPath = scriptPath.startsWith('scripts/') ? scriptPath.substring(8) : scriptPath; const relativeScriptPath = scriptPath.startsWith('scripts/') ? scriptPath.substring(8) : scriptPath;
try { try {
// Create temporary key file if using key authentication
if (server.auth_type === 'key' || server.auth_type === 'both') {
tempKeyPath = this.createTempKeyFile(server);
}
// Build SSH command based on authentication type // Build SSH command based on authentication type
const { command, args } = this.buildSSHCommand(server, tempKeyPath); const { command, args } = this.buildSSHCommand(server);
// Add the script execution command to the args // Add the script execution command to the args
args.push(`cd /tmp/scripts && chmod +x ${relativeScriptPath} && export TERM=xterm-256color && export COLUMNS=120 && export LINES=30 && export COLORTERM=truecolor && export FORCE_COLOR=1 && export NO_COLOR=0 && export CLICOLOR=1 && export CLICOLOR_FORCE=1 && bash ${relativeScriptPath}`); args.push(`cd /tmp/scripts && chmod +x ${relativeScriptPath} && export TERM=xterm-256color && export COLUMNS=120 && export LINES=30 && export COLORTERM=truecolor && export FORCE_COLOR=1 && export NO_COLOR=0 && export CLICOLOR=1 && export CLICOLOR_FORCE=1 && bash ${relativeScriptPath}`);
@@ -193,30 +135,10 @@ class SSHExecutionService {
process: sshCommand, process: sshCommand,
kill: () => { kill: () => {
sshCommand.kill('SIGTERM'); sshCommand.kill('SIGTERM');
// Clean up temporary key file
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
} }
}); });
} catch (error) { } catch (error) {
// Clean up temporary key file on error
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
reject(error); reject(error);
} }
}); });
@@ -235,35 +157,24 @@ class SSHExecutionService {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async transferScriptsFolder(server, onData, onError) { async transferScriptsFolder(server, onData, onError) {
const { ip, user, password, auth_type = 'password', ssh_key, ssh_key_passphrase, ssh_port = 22 } = server; const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_key_path, ssh_port = 22 } = server;
/** @type {string|null} */
let tempKeyPath = null;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
// Create temporary key file if using key authentication
if (auth_type === 'key' || auth_type === 'both') {
if (ssh_key) {
tempKeyPath = this.createTempKeyFile(server);
}
}
// Build rsync command based on authentication type // Build rsync command based on authentication type
let rshCommand; let rshCommand;
if (auth_type === 'key' && tempKeyPath) { if (auth_type === 'key') {
if (ssh_key_passphrase) { if (!ssh_key_path || !existsSync(ssh_key_path)) {
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`; throw new Error('SSH key file not found');
} else {
rshCommand = `ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
} }
} else if (auth_type === 'both' && tempKeyPath) {
if (ssh_key_passphrase) { if (ssh_key_passphrase) {
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`; rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
} else { } else {
rshCommand = `ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`; rshCommand = `ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
} }
} else { } else {
// Fallback to password authentication // Password authentication
rshCommand = `sshpass -p ${password} ssh -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`; rshCommand = `sshpass -p ${password} ssh -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
} }
@@ -292,17 +203,6 @@ class SSHExecutionService {
}); });
rsyncCommand.on('close', (code) => { rsyncCommand.on('close', (code) => {
// Clean up temporary key file
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
if (code === 0) { if (code === 0) {
resolve(); resolve();
} else { } else {
@@ -311,30 +211,10 @@ class SSHExecutionService {
}); });
rsyncCommand.on('error', (error) => { rsyncCommand.on('error', (error) => {
// Clean up temporary key file on error
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
reject(error); reject(error);
}); });
} catch (error) { } catch (error) {
// Clean up temporary key file on error
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
reject(error); reject(error);
} }
}); });
@@ -350,18 +230,10 @@ class SSHExecutionService {
* @returns {Promise<Object>} Process information * @returns {Promise<Object>} Process information
*/ */
async executeCommand(server, command, onData, onError, onExit) { async executeCommand(server, command, onData, onError, onExit) {
/** @type {string|null} */
let tempKeyPath = null;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
// Create temporary key file if using key authentication
if (server.auth_type === 'key' || server.auth_type === 'both') {
tempKeyPath = this.createTempKeyFile(server);
}
// Build SSH command based on authentication type // Build SSH command based on authentication type
const { command: sshCommandName, args } = this.buildSSHCommand(server, tempKeyPath); const { command: sshCommandName, args } = this.buildSSHCommand(server);
// Add the command to execute to the args // Add the command to execute to the args
args.push(command); args.push(command);
@@ -380,16 +252,6 @@ class SSHExecutionService {
}); });
sshCommand.onExit((e) => { sshCommand.onExit((e) => {
// Clean up temporary key file
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
onExit(e.exitCode); onExit(e.exitCode);
}); });
@@ -397,30 +259,10 @@ class SSHExecutionService {
process: sshCommand, process: sshCommand,
kill: () => { kill: () => {
sshCommand.kill('SIGTERM'); sshCommand.kill('SIGTERM');
// Clean up temporary key file
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
} }
}); });
} catch (error) { } catch (error) {
// Clean up temporary key file on error
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
reject(error); reject(error);
} }
}); });

View File

@@ -1,5 +1,5 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync } from 'fs'; import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync, readFileSync, existsSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
@@ -21,9 +21,6 @@ class SSHService {
let authPromise; let authPromise;
if (auth_type === 'key') { if (auth_type === 'key') {
authPromise = this.testWithSSHKey(server); authPromise = this.testWithSSHKey(server);
} else if (auth_type === 'both') {
// Try SSH key first, then password
authPromise = this.testWithSSHKey(server).catch(() => this.testWithSshpass(server));
} else { } else {
// Default to password authentication // Default to password authentication
authPromise = this.testWithSshpass(server).catch(() => this.testWithExpect(server)); authPromise = this.testWithSshpass(server).catch(() => this.testWithExpect(server));
@@ -540,31 +537,20 @@ expect {
* @returns {Promise<Object>} Connection test result * @returns {Promise<Object>} Connection test result
*/ */
async testWithSSHKey(server) { async testWithSSHKey(server) {
const { ip, user, ssh_key, ssh_key_passphrase, ssh_port = 22 } = server; const { ip, user, ssh_key_path, ssh_key_passphrase, ssh_port = 22 } = server;
if (!ssh_key) { if (!ssh_key_path || !existsSync(ssh_key_path)) {
throw new Error('SSH key not provided'); throw new Error('SSH key file not found');
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timeout = 10000; const timeout = 10000;
let resolved = false; let resolved = false;
let tempKeyPath = null;
try { try {
// Create temporary key file
const tempDir = mkdtempSync(join(tmpdir(), 'ssh-key-'));
tempKeyPath = join(tempDir, 'private_key');
// Write the private key to temporary file
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
const normalizedKey = ssh_key.trimEnd() + '\n';
writeFileSync(tempKeyPath, normalizedKey);
chmodSync(tempKeyPath, 0o600); // Set proper permissions
// Build SSH command // Build SSH command
const sshArgs = [ const sshArgs = [
'-i', tempKeyPath, '-i', ssh_key_path,
'-p', ssh_port.toString(), '-p', ssh_port.toString(),
'-o', 'ConnectTimeout=10', '-o', 'ConnectTimeout=10',
'-o', 'StrictHostKeyChecking=no', '-o', 'StrictHostKeyChecking=no',
@@ -662,22 +648,82 @@ expect {
resolved = true; resolved = true;
reject(error); reject(error);
} }
} finally {
// Clean up temporary key file
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
// Also remove the temp directory
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
} }
}); });
} }
/**
* Generate SSH key pair for a server
* @param {number} serverId - Server ID for key file naming
* @returns {Promise<{privateKey: string, publicKey: string}>}
*/
async generateKeyPair(serverId) {
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
return new Promise((resolve, reject) => {
const sshKeygen = spawn('ssh-keygen', [
'-t', 'ed25519',
'-f', keyPath,
'-N', '', // No passphrase
'-C', 'pve-scripts-local'
], {
stdio: ['pipe', 'pipe', 'pipe']
});
let errorOutput = '';
sshKeygen.stderr.on('data', (data) => {
errorOutput += data.toString();
});
sshKeygen.on('close', (code) => {
if (code === 0) {
try {
// Read the generated private key
const privateKey = readFileSync(keyPath, 'utf8');
// Read the generated public key
const publicKeyPath = keyPath + '.pub';
const publicKey = readFileSync(publicKeyPath, 'utf8');
// Set proper permissions
chmodSync(keyPath, 0o600);
chmodSync(publicKeyPath, 0o644);
resolve({
privateKey,
publicKey: publicKey.trim()
});
} catch (error) {
reject(new Error(`Failed to read generated key files: ${error instanceof Error ? error.message : String(error)}`));
}
} else {
reject(new Error(`ssh-keygen failed: ${errorOutput}`));
}
});
sshKeygen.on('error', (error) => {
reject(new Error(`Failed to run ssh-keygen: ${error.message}`));
});
});
}
/**
* Get public key from private key file
* @param {string} keyPath - Path to private key file
* @returns {string} Public key content
*/
getPublicKey(keyPath) {
const publicKeyPath = keyPath + '.pub';
if (!existsSync(publicKeyPath)) {
throw new Error('Public key file not found');
}
return readFileSync(publicKeyPath, 'utf8').trim();
}
} }
// Singleton instance // Singleton instance

View File

@@ -4,9 +4,11 @@ export interface Server {
ip: string; ip: string;
user: string; user: string;
password?: string; password?: string;
auth_type?: 'password' | 'key' | 'both'; auth_type?: 'password' | 'key';
ssh_key?: string; ssh_key?: string;
ssh_key_passphrase?: string; ssh_key_passphrase?: string;
ssh_key_path?: string;
key_generated?: number;
ssh_port?: number; ssh_port?: number;
color?: string; color?: string;
created_at: string; created_at: string;
@@ -18,9 +20,11 @@ export interface CreateServerData {
ip: string; ip: string;
user: string; user: string;
password?: string; password?: string;
auth_type?: 'password' | 'key' | 'both'; auth_type?: 'password' | 'key';
ssh_key?: string; ssh_key?: string;
ssh_key_passphrase?: string; ssh_key_passphrase?: string;
ssh_key_path?: string;
key_generated?: number;
ssh_port?: number; ssh_port?: number;
color?: string; color?: string;
} }