Skip to content

Commit ff1ab35

Browse files
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
1 parent e8be9e7 commit ff1ab35

File tree

9 files changed

+984
-141
lines changed

9 files changed

+984
-141
lines changed
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
'use client';
2+
3+
import { useState, useRef } from 'react';
4+
import { Button } from './ui/button';
5+
6+
interface SSHKeyInputProps {
7+
value: string;
8+
onChange: (value: string) => void;
9+
onError?: (error: string) => void;
10+
disabled?: boolean;
11+
}
12+
13+
export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHKeyInputProps) {
14+
const [inputMode, setInputMode] = useState<'upload' | 'paste'>('upload');
15+
const [isDragOver, setIsDragOver] = useState(false);
16+
const fileInputRef = useRef<HTMLInputElement>(null);
17+
18+
const validateSSHKey = (keyContent: string): boolean => {
19+
const trimmed = keyContent.trim();
20+
return (
21+
trimmed.includes('BEGIN') &&
22+
trimmed.includes('PRIVATE KEY') &&
23+
trimmed.includes('END') &&
24+
trimmed.includes('PRIVATE KEY')
25+
);
26+
};
27+
28+
const handleFileUpload = (file: File) => {
29+
if (!file) return;
30+
31+
const reader = new FileReader();
32+
reader.onload = (e) => {
33+
const content = e.target?.result as string;
34+
if (validateSSHKey(content)) {
35+
onChange(content);
36+
onError?.('');
37+
} else {
38+
onError?.('Invalid SSH key format. Please ensure the file contains a valid private key.');
39+
}
40+
};
41+
reader.onerror = () => {
42+
onError?.('Failed to read the file. Please try again.');
43+
};
44+
reader.readAsText(file);
45+
};
46+
47+
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
48+
const file = event.target.files?.[0];
49+
if (file) {
50+
handleFileUpload(file);
51+
}
52+
};
53+
54+
const handleDragOver = (event: React.DragEvent) => {
55+
event.preventDefault();
56+
setIsDragOver(true);
57+
};
58+
59+
const handleDragLeave = (event: React.DragEvent) => {
60+
event.preventDefault();
61+
setIsDragOver(false);
62+
};
63+
64+
const handleDrop = (event: React.DragEvent) => {
65+
event.preventDefault();
66+
setIsDragOver(false);
67+
68+
const file = event.dataTransfer.files[0];
69+
if (file) {
70+
handleFileUpload(file);
71+
}
72+
};
73+
74+
const handlePasteChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
75+
const content = event.target.value;
76+
onChange(content);
77+
78+
if (content.trim() && !validateSSHKey(content)) {
79+
onError?.('Invalid SSH key format. Please ensure the content is a valid private key.');
80+
} else {
81+
onError?.('');
82+
}
83+
};
84+
85+
const getKeyFingerprint = (keyContent: string): string => {
86+
// This is a simplified fingerprint - in a real implementation,
87+
// you might want to use a library to generate proper SSH key fingerprints
88+
if (!keyContent.trim()) return '';
89+
90+
const lines = keyContent.trim().split('\n');
91+
const keyLine = lines.find(line =>
92+
line.includes('BEGIN') && line.includes('PRIVATE KEY')
93+
);
94+
95+
if (keyLine) {
96+
const keyType = keyLine.includes('RSA') ? 'RSA' :
97+
keyLine.includes('ED25519') ? 'ED25519' :
98+
keyLine.includes('ECDSA') ? 'ECDSA' : 'Unknown';
99+
return `${keyType} key (${keyContent.length} characters)`;
100+
}
101+
102+
return 'Unknown key type';
103+
};
104+
105+
return (
106+
<div className="space-y-4">
107+
{/* Mode Toggle */}
108+
<div className="flex space-x-2">
109+
<Button
110+
type="button"
111+
variant={inputMode === 'upload' ? 'default' : 'outline'}
112+
size="sm"
113+
onClick={() => setInputMode('upload')}
114+
disabled={disabled}
115+
>
116+
Upload File
117+
</Button>
118+
<Button
119+
type="button"
120+
variant={inputMode === 'paste' ? 'default' : 'outline'}
121+
size="sm"
122+
onClick={() => setInputMode('paste')}
123+
disabled={disabled}
124+
>
125+
Paste Key
126+
</Button>
127+
</div>
128+
129+
{/* File Upload Mode */}
130+
{inputMode === 'upload' && (
131+
<div
132+
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
133+
isDragOver
134+
? 'border-primary bg-primary/5'
135+
: 'border-border hover:border-primary/50'
136+
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
137+
onDragOver={handleDragOver}
138+
onDragLeave={handleDragLeave}
139+
onDrop={handleDrop}
140+
onClick={() => !disabled && fileInputRef.current?.click()}
141+
>
142+
<input
143+
ref={fileInputRef}
144+
type="file"
145+
accept=".pem,.key,.id_rsa,.id_ed25519,.id_ecdsa"
146+
onChange={handleFileSelect}
147+
className="hidden"
148+
disabled={disabled}
149+
/>
150+
<div className="space-y-2">
151+
<div className="text-lg">📁</div>
152+
<p className="text-sm text-muted-foreground">
153+
Drag and drop your SSH private key here, or click to browse
154+
</p>
155+
<p className="text-xs text-muted-foreground">
156+
Supported formats: RSA, ED25519, ECDSA (.pem, .key, .id_rsa, etc.)
157+
</p>
158+
</div>
159+
</div>
160+
)}
161+
162+
{/* Paste Mode */}
163+
{inputMode === 'paste' && (
164+
<div className="space-y-2">
165+
<label className="text-sm font-medium text-muted-foreground">
166+
Paste your SSH private key:
167+
</label>
168+
<textarea
169+
value={value}
170+
onChange={handlePasteChange}
171+
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABFwAAAAdzc2gtcn...&#10;-----END OPENSSH PRIVATE KEY-----"
172+
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"
173+
disabled={disabled}
174+
/>
175+
</div>
176+
)}
177+
178+
{/* Key Information */}
179+
{value && (
180+
<div className="p-3 bg-muted rounded-md">
181+
<div className="text-sm">
182+
<span className="font-medium">Key detected:</span> {getKeyFingerprint(value)}
183+
</div>
184+
<div className="text-xs text-muted-foreground mt-1">
185+
⚠️ Keep your private keys secure. This key will be stored in the database.
186+
</div>
187+
</div>
188+
)}
189+
</div>
190+
);
191+
}

0 commit comments

Comments
 (0)