Skip to content

Commit 3459fe3

Browse files
Merge pull request #163 from community-scripts/fix/ssh_keys
fix: implement persistent SSH key storage with key generation
2 parents 0e95c12 + 6580f31 commit 3459fe3

File tree

13 files changed

+695
-269
lines changed

13 files changed

+695
-269
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
db.sqlite
1717
data/settings.db
1818

19+
# ssh keys (sensitive)
20+
data/ssh-keys/
21+
1922
# next.js
2023
/.next/
2124
/out/

src/app/_components/HelpModal.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,15 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
5555
<ul className="text-sm text-muted-foreground space-y-2">
5656
<li><strong>Password:</strong> Use username and password authentication</li>
5757
<li><strong>SSH Key:</strong> Use SSH key pair for secure authentication</li>
58-
<li><strong>Both:</strong> Try SSH key first, fallback to password if needed</li>
5958
</ul>
59+
<div className="mt-3 p-3 bg-blue-50 dark:bg-blue-950/20 rounded-md">
60+
<h5 className="font-medium text-blue-900 dark:text-blue-100 mb-2">SSH Key Features:</h5>
61+
<ul className="text-xs text-blue-800 dark:text-blue-200 space-y-1">
62+
<li><strong>Generate Key Pair:</strong> Create new SSH keys automatically</li>
63+
<li><strong>View Public Key:</strong> Copy public key for server setup</li>
64+
<li><strong>Persistent Storage:</strong> Keys are stored securely on disk</li>
65+
</ul>
66+
</div>
6067
</div>
6168

6269
<div className="p-4 border border-border rounded-lg">
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { X, Copy, Check, Server, Globe } from 'lucide-react';
5+
import { Button } from './ui/button';
6+
7+
interface PublicKeyModalProps {
8+
isOpen: boolean;
9+
onClose: () => void;
10+
publicKey: string;
11+
serverName: string;
12+
serverIp: string;
13+
}
14+
15+
export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverIp }: PublicKeyModalProps) {
16+
const [copied, setCopied] = useState(false);
17+
18+
if (!isOpen) return null;
19+
20+
const handleCopy = async () => {
21+
try {
22+
// Try modern clipboard API first
23+
if (navigator.clipboard && window.isSecureContext) {
24+
await navigator.clipboard.writeText(publicKey);
25+
setCopied(true);
26+
setTimeout(() => setCopied(false), 2000);
27+
} else {
28+
// Fallback for older browsers or non-HTTPS
29+
const textArea = document.createElement('textarea');
30+
textArea.value = publicKey;
31+
textArea.style.position = 'fixed';
32+
textArea.style.left = '-999999px';
33+
textArea.style.top = '-999999px';
34+
document.body.appendChild(textArea);
35+
textArea.focus();
36+
textArea.select();
37+
38+
try {
39+
document.execCommand('copy');
40+
setCopied(true);
41+
setTimeout(() => setCopied(false), 2000);
42+
} catch (fallbackError) {
43+
console.error('Fallback copy failed:', fallbackError);
44+
// If all else fails, show the key in an alert
45+
alert('Please manually copy this key:\n\n' + publicKey);
46+
}
47+
48+
document.body.removeChild(textArea);
49+
}
50+
} catch (error) {
51+
console.error('Failed to copy to clipboard:', error);
52+
// Fallback: show the key in an alert
53+
alert('Please manually copy this key:\n\n' + publicKey);
54+
}
55+
};
56+
57+
return (
58+
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
59+
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border">
60+
{/* Header */}
61+
<div className="flex items-center justify-between p-6 border-b border-border">
62+
<div className="flex items-center gap-3">
63+
<div className="p-2 bg-blue-100 rounded-lg">
64+
<Server className="h-6 w-6 text-blue-600" />
65+
</div>
66+
<div>
67+
<h2 className="text-xl font-semibold text-card-foreground">SSH Public Key</h2>
68+
<p className="text-sm text-muted-foreground">Add this key to your server&apos;s authorized_keys</p>
69+
</div>
70+
</div>
71+
<Button
72+
variant="ghost"
73+
size="icon"
74+
onClick={onClose}
75+
className="h-8 w-8"
76+
>
77+
<X className="h-4 w-4" />
78+
</Button>
79+
</div>
80+
81+
{/* Content */}
82+
<div className="p-6 space-y-6">
83+
{/* Server Info */}
84+
<div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
85+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
86+
<Server className="h-4 w-4" />
87+
<span className="font-medium">{serverName}</span>
88+
</div>
89+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
90+
<Globe className="h-4 w-4" />
91+
<span>{serverIp}</span>
92+
</div>
93+
</div>
94+
95+
{/* Instructions */}
96+
<div className="space-y-2">
97+
<h3 className="font-medium text-foreground">Instructions:</h3>
98+
<ol className="text-sm text-muted-foreground space-y-1 list-decimal list-inside">
99+
<li>Copy the public key below</li>
100+
<li>SSH into your server: <code className="bg-muted px-1 rounded">ssh root@{serverIp}</code></li>
101+
<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>
102+
<li>Set proper permissions: <code className="bg-muted px-1 rounded">chmod 600 ~/.ssh/authorized_keys</code></li>
103+
</ol>
104+
</div>
105+
106+
{/* Public Key */}
107+
<div className="space-y-2">
108+
<div className="flex items-center justify-between">
109+
<label className="text-sm font-medium text-foreground">Public Key:</label>
110+
<Button
111+
variant="outline"
112+
size="sm"
113+
onClick={handleCopy}
114+
className="gap-2"
115+
>
116+
{copied ? (
117+
<>
118+
<Check className="h-4 w-4" />
119+
Copied!
120+
</>
121+
) : (
122+
<>
123+
<Copy className="h-4 w-4" />
124+
Copy
125+
</>
126+
)}
127+
</Button>
128+
</div>
129+
<textarea
130+
value={publicKey}
131+
readOnly
132+
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"
133+
placeholder="Public key will appear here..."
134+
/>
135+
</div>
136+
137+
{/* Footer */}
138+
<div className="flex justify-end gap-3 pt-4 border-t border-border">
139+
<Button variant="outline" onClick={onClose}>
140+
Close
141+
</Button>
142+
</div>
143+
</div>
144+
</div>
145+
</div>
146+
);
147+
}

src/app/_components/ServerForm.tsx

Lines changed: 117 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { useState, useEffect } from 'react';
44
import type { CreateServerData } from '../../types/server';
55
import { Button } from './ui/button';
66
import { SSHKeyInput } from './SSHKeyInput';
7+
import { PublicKeyModal } from './PublicKeyModal';
8+
import { Key } from 'lucide-react';
79

810
interface ServerFormProps {
911
onSubmit: (data: CreateServerData) => void;
@@ -30,6 +32,11 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
3032
const [errors, setErrors] = useState<Partial<Record<keyof CreateServerData, string>>>({});
3133
const [sshKeyError, setSshKeyError] = useState<string>('');
3234
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
35+
const [isGeneratingKey, setIsGeneratingKey] = useState(false);
36+
const [showPublicKeyModal, setShowPublicKeyModal] = useState(false);
37+
const [generatedPublicKey, setGeneratedPublicKey] = useState('');
38+
const [, setIsGeneratedKey] = useState(false);
39+
const [, setGeneratedServerId] = useState<number | null>(null);
3340

3441
useEffect(() => {
3542
const loadColorCodingSetting = async () => {
@@ -75,25 +82,18 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
7582
// Validate authentication based on auth_type
7683
const authType = formData.auth_type ?? 'password';
7784

78-
if (authType === 'password' || authType === 'both') {
85+
if (authType === 'password') {
7986
if (!formData.password?.trim()) {
8087
newErrors.password = 'Password is required for password authentication';
8188
}
8289
}
8390

84-
if (authType === 'key' || authType === 'both') {
91+
if (authType === 'key') {
8592
if (!formData.ssh_key?.trim()) {
8693
newErrors.ssh_key = 'SSH key is required for key authentication';
8794
}
8895
}
8996

90-
// Check if at least one authentication method is provided
91-
if (authType === 'both') {
92-
if (!formData.password?.trim() && !formData.ssh_key?.trim()) {
93-
newErrors.password = 'At least one authentication method (password or SSH key) is required';
94-
newErrors.ssh_key = 'At least one authentication method (password or SSH key) is required';
95-
}
96-
}
9797

9898
setErrors(newErrors);
9999
return Object.keys(newErrors).length === 0 && !sshKeyError;
@@ -127,6 +127,54 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
127127
if (errors[field]) {
128128
setErrors(prev => ({ ...prev, [field]: undefined }));
129129
}
130+
131+
// Reset generated key state when switching auth types
132+
if (field === 'auth_type') {
133+
setIsGeneratedKey(false);
134+
setGeneratedPublicKey('');
135+
}
136+
};
137+
138+
const handleGenerateKeyPair = async () => {
139+
setIsGeneratingKey(true);
140+
try {
141+
const response = await fetch('/api/servers/generate-keypair', {
142+
method: 'POST',
143+
headers: {
144+
'Content-Type': 'application/json',
145+
},
146+
});
147+
148+
if (!response.ok) {
149+
throw new Error('Failed to generate key pair');
150+
}
151+
152+
const data = await response.json() as { success: boolean; privateKey?: string; publicKey?: string; serverId?: number; error?: string };
153+
154+
if (data.success) {
155+
const serverId = data.serverId ?? 0;
156+
const keyPath = `data/ssh-keys/server_${serverId}_key`;
157+
158+
setFormData(prev => ({
159+
...prev,
160+
ssh_key: data.privateKey ?? '',
161+
ssh_key_path: keyPath,
162+
key_generated: 1
163+
}));
164+
setGeneratedPublicKey(data.publicKey ?? '');
165+
setGeneratedServerId(serverId);
166+
setIsGeneratedKey(true);
167+
setShowPublicKeyModal(true);
168+
setSshKeyError('');
169+
} else {
170+
throw new Error(data.error ?? 'Failed to generate key pair');
171+
}
172+
} catch (error) {
173+
console.error('Error generating key pair:', error);
174+
setSshKeyError(error instanceof Error ? error.message : 'Failed to generate key pair');
175+
} finally {
176+
setIsGeneratingKey(false);
177+
}
130178
};
131179

132180
const handleSSHKeyChange = (value: string) => {
@@ -137,6 +185,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
137185
};
138186

139187
return (
188+
<>
140189
<form onSubmit={handleSubmit} className="space-y-6">
141190
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
142191
<div>
@@ -221,7 +270,6 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
221270
>
222271
<option value="password">Password Only</option>
223272
<option value="key">SSH Key Only</option>
224-
<option value="both">Both Password & SSH Key</option>
225273
</select>
226274
</div>
227275

@@ -247,10 +295,10 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
247295
</div>
248296

249297
{/* Password Authentication */}
250-
{(formData.auth_type === 'password' || formData.auth_type === 'both') && (
298+
{formData.auth_type === 'password' && (
251299
<div>
252300
<label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
253-
Password {formData.auth_type === 'both' ? '(Optional)' : '*'}
301+
Password *
254302
</label>
255303
<input
256304
type="password"
@@ -267,19 +315,55 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
267315
)}
268316

269317
{/* SSH Key Authentication */}
270-
{(formData.auth_type === 'key' || formData.auth_type === 'both') && (
318+
{formData.auth_type === 'key' && (
271319
<div className="space-y-4">
272320
<div>
273-
<label className="block text-sm font-medium text-muted-foreground mb-1">
274-
SSH Private Key {formData.auth_type === 'both' ? '(Optional)' : '*'}
275-
</label>
276-
<SSHKeyInput
277-
value={formData.ssh_key ?? ''}
278-
onChange={handleSSHKeyChange}
279-
onError={setSshKeyError}
280-
/>
281-
{errors.ssh_key && <p className="mt-1 text-sm text-destructive">{errors.ssh_key}</p>}
282-
{sshKeyError && <p className="mt-1 text-sm text-destructive">{sshKeyError}</p>}
321+
<div className="flex items-center justify-between mb-1">
322+
<label className="block text-sm font-medium text-muted-foreground">
323+
SSH Private Key *
324+
</label>
325+
<Button
326+
type="button"
327+
variant="outline"
328+
size="sm"
329+
onClick={handleGenerateKeyPair}
330+
disabled={isGeneratingKey}
331+
className="gap-2"
332+
>
333+
<Key className="h-4 w-4" />
334+
{isGeneratingKey ? 'Generating...' : 'Generate Key Pair'}
335+
</Button>
336+
</div>
337+
338+
{/* Show manual key input only if no key has been generated */}
339+
{!formData.key_generated && (
340+
<>
341+
<SSHKeyInput
342+
value={formData.ssh_key ?? ''}
343+
onChange={handleSSHKeyChange}
344+
onError={setSshKeyError}
345+
/>
346+
{errors.ssh_key && <p className="mt-1 text-sm text-destructive">{errors.ssh_key}</p>}
347+
{sshKeyError && <p className="mt-1 text-sm text-destructive">{sshKeyError}</p>}
348+
</>
349+
)}
350+
351+
{/* Show generated key status */}
352+
{formData.key_generated && (
353+
<div className="p-3 bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800 rounded-md">
354+
<div className="flex items-center gap-2">
355+
<svg className="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
356+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
357+
</svg>
358+
<span className="text-sm font-medium text-green-800 dark:text-green-200">
359+
SSH key pair generated successfully
360+
</span>
361+
</div>
362+
<p className="text-xs text-green-700 dark:text-green-300 mt-1">
363+
The private key has been generated and will be saved with the server.
364+
</p>
365+
</div>
366+
)}
283367
</div>
284368

285369
<div>
@@ -323,6 +407,16 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
323407
</Button>
324408
</div>
325409
</form>
410+
411+
{/* Public Key Modal */}
412+
<PublicKeyModal
413+
isOpen={showPublicKeyModal}
414+
onClose={() => setShowPublicKeyModal(false)}
415+
publicKey={generatedPublicKey}
416+
serverName={formData.name || 'New Server'}
417+
serverIp={formData.ip}
418+
/>
419+
</>
326420
);
327421
}
328422

0 commit comments

Comments
 (0)