diff --git a/src/app/_components/ConfigurationModal.tsx b/src/app/_components/ConfigurationModal.tsx index 48e0d47..ade4da3 100644 --- a/src/app/_components/ConfigurationModal.tsx +++ b/src/app/_components/ConfigurationModal.tsx @@ -58,6 +58,11 @@ export function ConfigurationModal({ // Advanced mode state const [advancedVars, setAdvancedVars] = useState({}); + // Discovered SSH keys on the Proxmox host (advanced mode only) + const [discoveredSshKeys, setDiscoveredSshKeys] = useState([]); + const [discoveredSshKeysLoading, setDiscoveredSshKeysLoading] = useState(false); + const [discoveredSshKeysError, setDiscoveredSshKeysError] = useState(null); + // Validation errors const [errors, setErrors] = useState>({}); @@ -71,7 +76,6 @@ export function ConfigurationModal({ } else { // Advanced mode: all vars with defaults const defaults: EnvVars = { - var_ctid: '', // Empty = use next available ID // Resources from JSON var_cpu: resources?.cpu ?? 1, var_ram: resources?.ram ?? 1024, @@ -88,7 +92,6 @@ export function ConfigurationModal({ var_mtu: 1500, var_mac: '', var_ns: '', - var_searchdomain: '', // Identity var_hostname: slug, @@ -121,6 +124,38 @@ export function ConfigurationModal({ } }, [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 const validateIPv4 = (ip: string): boolean => { 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)) { 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); @@ -285,18 +312,23 @@ export function ConfigurationModal({ if ((hasPassword || hasSSHKey) && envVars.var_ssh !== 'no') { 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.) const cleaned: EnvVars = {}; for (const [key, value] of Object.entries(envVars)) { if (value !== '' && value !== undefined) { - // Send var_ctid as number so the script receives a numeric ID - if (key === 'var_ctid') { - cleaned[key] = Number(value); - } else { - cleaned[key] = value; - } + cleaned[key] = value; } } @@ -389,35 +421,6 @@ export function ConfigurationModal({ ) : ( /* Advanced Mode */
- {/* Container ID (CTID) - at top so user can set a specific ID */} -
-

Container ID (CTID)

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

{errors.var_ctid}

- )} -

- Leave empty to use the next available ID. Must be 100 or greater. -

-
-
-
- {/* Resources */}

Resources

@@ -657,17 +660,6 @@ export function ConfigurationModal({

{errors.var_ns}

)}
-
- - updateAdvancedVar('var_searchdomain', e.target.value)} - placeholder="e.g. local, home.lan" - /> -
@@ -699,13 +691,13 @@ export function ConfigurationModal({
updateAdvancedVar('var_tags', e.target.value)} - placeholder="community-script" + placeholder="e.g. tag1; tag2" />
@@ -732,11 +724,40 @@ export function ConfigurationModal({ + {discoveredSshKeysLoading && ( +

Detecting SSH keys...

+ )} + {discoveredSshKeysError && !discoveredSshKeysLoading && ( +

Could not detect keys on host

+ )} + {discoveredSshKeys.length > 0 && !discoveredSshKeysLoading && ( +
+ + +
+ )} updateAdvancedVar('var_ssh_authorized_key', e.target.value)} - placeholder="ssh-rsa AAAA..." + placeholder="Or paste a public key: ssh-rsa AAAA..." /> diff --git a/src/app/api/servers/[id]/discover-ssh-keys/route.ts b/src/app/api/servers/[id]/discover-ssh-keys/route.ts new file mode 100644 index 0000000..a97cde7 --- /dev/null +++ b/src/app/api/servers/[id]/discover-ssh-keys/route.ts @@ -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 } + ); + } +}