Merge pull request #468 from community-scripts/fix/465
fix: advanced modal SSH key discovery and tags delimiter
This commit is contained in:
@@ -58,6 +58,11 @@ export function ConfigurationModal({
|
|||||||
// Advanced mode state
|
// Advanced mode state
|
||||||
const [advancedVars, setAdvancedVars] = useState<EnvVars>({});
|
const [advancedVars, setAdvancedVars] = useState<EnvVars>({});
|
||||||
|
|
||||||
|
// Discovered SSH keys on the Proxmox host (advanced mode only)
|
||||||
|
const [discoveredSshKeys, setDiscoveredSshKeys] = useState<string[]>([]);
|
||||||
|
const [discoveredSshKeysLoading, setDiscoveredSshKeysLoading] = useState(false);
|
||||||
|
const [discoveredSshKeysError, setDiscoveredSshKeysError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Validation errors
|
// Validation errors
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
@@ -71,7 +76,6 @@ export function ConfigurationModal({
|
|||||||
} else {
|
} else {
|
||||||
// Advanced mode: all vars with defaults
|
// Advanced mode: all vars with defaults
|
||||||
const defaults: EnvVars = {
|
const defaults: EnvVars = {
|
||||||
var_ctid: '', // Empty = use next available ID
|
|
||||||
// Resources from JSON
|
// Resources from JSON
|
||||||
var_cpu: resources?.cpu ?? 1,
|
var_cpu: resources?.cpu ?? 1,
|
||||||
var_ram: resources?.ram ?? 1024,
|
var_ram: resources?.ram ?? 1024,
|
||||||
@@ -88,7 +92,6 @@ export function ConfigurationModal({
|
|||||||
var_mtu: 1500,
|
var_mtu: 1500,
|
||||||
var_mac: '',
|
var_mac: '',
|
||||||
var_ns: '',
|
var_ns: '',
|
||||||
var_searchdomain: '',
|
|
||||||
|
|
||||||
// Identity
|
// Identity
|
||||||
var_hostname: slug,
|
var_hostname: slug,
|
||||||
@@ -121,6 +124,38 @@ export function ConfigurationModal({
|
|||||||
}
|
}
|
||||||
}, [actualScript, server, mode, resources, slug]);
|
}, [actualScript, server, mode, resources, slug]);
|
||||||
|
|
||||||
|
// Discover SSH keys on the Proxmox host when advanced mode is open
|
||||||
|
useEffect(() => {
|
||||||
|
if (!server?.id || !isOpen || mode !== 'advanced') {
|
||||||
|
setDiscoveredSshKeys([]);
|
||||||
|
setDiscoveredSshKeysError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setDiscoveredSshKeysLoading(true);
|
||||||
|
setDiscoveredSshKeysError(null);
|
||||||
|
fetch(`/api/servers/${server.id}/discover-ssh-keys`)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(res.status === 404 ? 'Server not found' : res.statusText);
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((data: { keys?: string[] }) => {
|
||||||
|
if (!cancelled && Array.isArray(data.keys)) setDiscoveredSshKeys(data.keys);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setDiscoveredSshKeys([]);
|
||||||
|
setDiscoveredSshKeysError(err instanceof Error ? err.message : 'Could not detect keys');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setDiscoveredSshKeysLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [server?.id, isOpen, mode]);
|
||||||
|
|
||||||
// Validation functions
|
// Validation functions
|
||||||
const validateIPv4 = (ip: string): boolean => {
|
const validateIPv4 = (ip: string): boolean => {
|
||||||
if (!ip) return true; // Empty is allowed (auto)
|
if (!ip) return true; // Empty is allowed (auto)
|
||||||
@@ -213,14 +248,6 @@ export function ConfigurationModal({
|
|||||||
if (advancedVars.var_vlan && !validatePositiveInt(advancedVars.var_vlan as string | number | undefined)) {
|
if (advancedVars.var_vlan && !validatePositiveInt(advancedVars.var_vlan as string | number | undefined)) {
|
||||||
newErrors.var_vlan = 'Must be a positive integer';
|
newErrors.var_vlan = 'Must be a positive integer';
|
||||||
}
|
}
|
||||||
// Container ID (CTID): if set, must be integer >= 100
|
|
||||||
const ctidVal = advancedVars.var_ctid;
|
|
||||||
if (ctidVal !== '' && ctidVal !== undefined && typeof ctidVal !== 'boolean') {
|
|
||||||
const ctidNum = typeof ctidVal === 'string' ? parseInt(ctidVal, 10) : ctidVal;
|
|
||||||
if (isNaN(ctidNum) || ctidNum < 100) {
|
|
||||||
newErrors.var_ctid = 'Must be 100 or greater';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
@@ -285,18 +312,23 @@ export function ConfigurationModal({
|
|||||||
if ((hasPassword || hasSSHKey) && envVars.var_ssh !== 'no') {
|
if ((hasPassword || hasSSHKey) && envVars.var_ssh !== 'no') {
|
||||||
envVars.var_ssh = 'yes';
|
envVars.var_ssh = 'yes';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize var_tags: accept both comma and semicolon, output comma-separated
|
||||||
|
const rawTags = envVars.var_tags;
|
||||||
|
if (typeof rawTags === 'string' && rawTags.trim() !== '') {
|
||||||
|
envVars.var_tags = rawTags
|
||||||
|
.split(/[,;]/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(',');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove empty string values (but keep 0, false, etc.)
|
// Remove empty string values (but keep 0, false, etc.)
|
||||||
const cleaned: EnvVars = {};
|
const cleaned: EnvVars = {};
|
||||||
for (const [key, value] of Object.entries(envVars)) {
|
for (const [key, value] of Object.entries(envVars)) {
|
||||||
if (value !== '' && value !== undefined) {
|
if (value !== '' && value !== undefined) {
|
||||||
// Send var_ctid as number so the script receives a numeric ID
|
cleaned[key] = value;
|
||||||
if (key === 'var_ctid') {
|
|
||||||
cleaned[key] = Number(value);
|
|
||||||
} else {
|
|
||||||
cleaned[key] = value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,35 +421,6 @@ export function ConfigurationModal({
|
|||||||
) : (
|
) : (
|
||||||
/* Advanced Mode */
|
/* Advanced Mode */
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Container ID (CTID) - at top so user can set a specific ID */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium text-foreground mb-4">Container ID (CTID)</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-foreground mb-2">
|
|
||||||
Container ID (CTID)
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="100"
|
|
||||||
value={typeof advancedVars.var_ctid === 'boolean' ? '' : (advancedVars.var_ctid ?? '')}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value;
|
|
||||||
updateAdvancedVar('var_ctid', v === '' ? '' : parseInt(v, 10) || '');
|
|
||||||
}}
|
|
||||||
placeholder="Auto (next available)"
|
|
||||||
className={errors.var_ctid ? 'border-destructive' : ''}
|
|
||||||
/>
|
|
||||||
{errors.var_ctid && (
|
|
||||||
<p className="mt-1 text-xs text-destructive">{errors.var_ctid}</p>
|
|
||||||
)}
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
Leave empty to use the next available ID. Must be 100 or greater.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Resources */}
|
{/* Resources */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium text-foreground mb-4">Resources</h3>
|
<h3 className="text-lg font-medium text-foreground mb-4">Resources</h3>
|
||||||
@@ -657,17 +660,6 @@ export function ConfigurationModal({
|
|||||||
<p className="mt-1 text-xs text-destructive">{errors.var_ns}</p>
|
<p className="mt-1 text-xs text-destructive">{errors.var_ns}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-foreground mb-2">
|
|
||||||
DNS Search Domain
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={typeof advancedVars.var_searchdomain === 'boolean' ? '' : String(advancedVars.var_searchdomain ?? '')}
|
|
||||||
onChange={(e) => updateAdvancedVar('var_searchdomain', e.target.value)}
|
|
||||||
placeholder="e.g. local, home.lan"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -699,13 +691,13 @@ export function ConfigurationModal({
|
|||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="block text-sm font-medium text-foreground mb-2">
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
Tags (comma-separated)
|
Tags (comma or semicolon separated)
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={typeof advancedVars.var_tags === 'boolean' ? '' : String(advancedVars.var_tags ?? '')}
|
value={typeof advancedVars.var_tags === 'boolean' ? '' : String(advancedVars.var_tags ?? '')}
|
||||||
onChange={(e) => updateAdvancedVar('var_tags', e.target.value)}
|
onChange={(e) => updateAdvancedVar('var_tags', e.target.value)}
|
||||||
placeholder="community-script"
|
placeholder="e.g. tag1; tag2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -732,11 +724,40 @@ export function ConfigurationModal({
|
|||||||
<label className="block text-sm font-medium text-foreground mb-2">
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
SSH Authorized Key
|
SSH Authorized Key
|
||||||
</label>
|
</label>
|
||||||
|
{discoveredSshKeysLoading && (
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">Detecting SSH keys...</p>
|
||||||
|
)}
|
||||||
|
{discoveredSshKeysError && !discoveredSshKeysLoading && (
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">Could not detect keys on host</p>
|
||||||
|
)}
|
||||||
|
{discoveredSshKeys.length > 0 && !discoveredSshKeysLoading && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<label htmlFor="discover-ssh-key" className="sr-only">Use detected key</label>
|
||||||
|
<select
|
||||||
|
id="discover-ssh-key"
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:ring-2 focus:ring-ring focus:outline-none mb-2"
|
||||||
|
value=""
|
||||||
|
onChange={(e) => {
|
||||||
|
const idx = e.target.value;
|
||||||
|
if (idx === '') return;
|
||||||
|
const key = discoveredSshKeys[Number(idx)];
|
||||||
|
if (key) updateAdvancedVar('var_ssh_authorized_key', key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">— Select or paste below —</option>
|
||||||
|
{discoveredSshKeys.map((key, i) => (
|
||||||
|
<option key={i} value={i}>
|
||||||
|
{key.length > 44 ? `${key.slice(0, 44)}...` : key}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={typeof advancedVars.var_ssh_authorized_key === 'boolean' ? '' : String(advancedVars.var_ssh_authorized_key ?? '')}
|
value={typeof advancedVars.var_ssh_authorized_key === 'boolean' ? '' : String(advancedVars.var_ssh_authorized_key ?? '')}
|
||||||
onChange={(e) => updateAdvancedVar('var_ssh_authorized_key', e.target.value)}
|
onChange={(e) => updateAdvancedVar('var_ssh_authorized_key', e.target.value)}
|
||||||
placeholder="ssh-rsa AAAA..."
|
placeholder="Or paste a public key: ssh-rsa AAAA..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
96
src/app/api/servers/[id]/discover-ssh-keys/route.ts
Normal file
96
src/app/api/servers/[id]/discover-ssh-keys/route.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getDatabase } from '../../../../../server/database-prisma';
|
||||||
|
import { getSSHExecutionService } from '../../../../../server/ssh-execution-service';
|
||||||
|
import type { Server } from '~/types/server';
|
||||||
|
|
||||||
|
const DISCOVER_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
/** Match lines that look like SSH public keys (same as build.func) */
|
||||||
|
const SSH_PUBKEY_RE = /^(ssh-(rsa|ed25519)|ecdsa-sha2-nistp256|sk-(ssh-ed25519|ecdsa-sha2-nistp256))\s+/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a command on the Proxmox host and return buffered stdout.
|
||||||
|
* Resolves when the process exits or rejects on timeout/spawn error.
|
||||||
|
*/
|
||||||
|
function runRemoteCommand(
|
||||||
|
server: Server,
|
||||||
|
command: string,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<{ stdout: string; exitCode: number }> {
|
||||||
|
const ssh = getSSHExecutionService();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const finish = (stdout: string, exitCode: number) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve({ stdout, exitCode });
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
reject(new Error('SSH discover keys timeout'));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
ssh
|
||||||
|
.executeCommand(
|
||||||
|
server,
|
||||||
|
command,
|
||||||
|
(data: string) => chunks.push(data),
|
||||||
|
() => {},
|
||||||
|
(code: number) => finish(chunks.join(''), code)
|
||||||
|
)
|
||||||
|
.catch((err) => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = await db.getServerById(id) as Server | null;
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json({ error: 'Server not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same paths as native build.func ssh_discover_default_files()
|
||||||
|
const remoteScript = `bash -c 'for f in /root/.ssh/authorized_keys /root/.ssh/authorized_keys2 /root/.ssh/*.pub /etc/ssh/authorized_keys /etc/ssh/authorized_keys.d/* 2>/dev/null; do [ -f "$f" ] && [ -r "$f" ] && grep -E "^(ssh-(rsa|ed25519)|ecdsa-sha2-nistp256|sk-)" "$f" 2>/dev/null; done | sort -u'`;
|
||||||
|
|
||||||
|
const { stdout } = await runRemoteCommand(server, remoteScript, DISCOVER_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const keys = stdout
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0 && SSH_PUBKEY_RE.test(line));
|
||||||
|
|
||||||
|
return NextResponse.json({ keys });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error discovering SSH keys:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user