/** * SSH Discovery API Endpoint * * POST /api/discover - Discover all hosts via SSH and return their status */ import { Router } from 'express'; import { execSync } from 'child_process'; import { homedir } from 'os'; import { PrismaClient } from '@prisma/client'; import { getHostConfigs } from '../config'; import { DiscoveryResponse } from '../types'; import { io } from '../index'; const router = Router(); const prisma = new PrismaClient(); interface HostDiscoveryResult { name: string; ip: string; online: boolean; containers?: string[]; services?: string[]; vms?: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }>; error?: string; } async function discoverHost( name: string, ip: string, sshUser: string, sshKeyPath?: string, sshPort?: number, hostType?: string ): Promise { try { const keyPath = (sshKeyPath || `${homedir()}/.ssh/id_ed25519`).replace(/^~/, homedir()); console.error(`DEBUG: ${name} keyPath=${keyPath}, user=${sshUser}`); const keyArg = `-i ${keyPath}`; const portArg = sshPort && sshPort !== 22 ? `-p ${sshPort}` : ''; const dockerCmd = `ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${keyArg} ${portArg} ${sshUser}@${ip} "docker ps --format '{{.Names}}'" 2>/dev/null`; const dockerOutput = execSync(dockerCmd, { encoding: 'utf-8', timeout: 15000 }); const containers = dockerOutput.trim().split('\n').filter(c => c.trim()); let services: string[] = []; if (hostType !== 'proxmox') { try { const systemdCmd = `ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${keyArg} ${portArg} ${sshUser}@${ip} "systemctl list-units --type=service --state=running --no-pager --no-legend --quiet | awk '{print \\$1}'" 2>/dev/null`; const systemdOutput = execSync(systemdCmd, { encoding: 'utf-8', timeout: 10000 }); services = systemdOutput.trim().split('\n').filter(s => s.trim()); } catch { console.error(`DEBUG: ${name} systemd discovery failed`); } } let vms: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }> = []; if (hostType === 'proxmox' || name === 'proxmox') { try { const lxcCmd = `ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${keyArg} ${portArg} ${sshUser}@${ip} "pct list" 2>/dev/null`; const lxcOutput = execSync(lxcCmd, { encoding: 'utf-8', timeout: 10000 }); const lxcLines = lxcOutput.trim().split('\n').slice(1); for (const line of lxcLines) { const parts = line.trim().split(/\s+/); if (parts.length >= 2) { vms.push({ id: parts[0], name: parts[1], status: parts[2] || 'unknown', type: 'lxc' }); } } const vmCmd = `ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${keyArg} ${portArg} ${sshUser}@${ip} "qm list" 2>/dev/null`; const vmOutput = execSync(vmCmd, { encoding: 'utf-8', timeout: 10000 }); const vmLines = vmOutput.trim().split('\n').slice(1); for (const line of vmLines) { const parts = line.trim().split(/\s+/); if (parts.length >= 2) { vms.push({ id: parts[0], name: parts[1], status: parts[2] || 'unknown', type: 'qemu' }); } } } catch { console.error(`DEBUG: ${name} Proxmox discovery failed`); } } return { name, ip, online: true, containers, services: services.length > 0 ? services : undefined, vms: vms.length > 0 ? vms : undefined, }; } catch (error: any) { return { name, ip, online: false, containers: [], error: error.message || 'Discovery failed', }; } } // POST /api/discover - Trigger discovery but return cached immediately router.post('/discover', async (req, res) => { try { // 1. Fetch from cache immediately for fast loading const cachedSetting = await prisma.settings.findUnique({ where: { key: 'last_discovery' } }); let previousState: DiscoveryResponse = { hosts: [], timestamp: new Date().toISOString(), errors: [], }; if (cachedSetting && cachedSetting.value) { try { previousState = JSON.parse(cachedSetting.value); } catch (e) { console.error('Failed to parse cached discovery state'); } } // 2. Return cached response to unblock the UI request res.json(previousState); // 3. Kick off background discovery runBackgroundDiscovery(); } catch (error: any) { res.status(500).json({ hosts: [], timestamp: new Date().toISOString(), errors: [error.message || 'Cache retrieval failed'], }); } }); async function runBackgroundDiscovery() { try { const hosts = await getHostConfigs(); if (hosts.length === 0) { const response: DiscoveryResponse = { hosts: [], timestamp: new Date().toISOString(), errors: ['No hosts configured'], }; await cacheAndEmit(response); return; } const results: HostDiscoveryResult[] = []; for (const host of hosts) { try { const keyPath = host.sshKeyPath?.replace(/^~/, homedir()); const result = await discoverHost( host.name, host.ip, host.sshUser, keyPath, host.sshPort, host.hostType ); results.push(result); } catch (error: any) { results.push({ name: host.name, ip: host.ip, online: false, containers: [], error: error.message || 'Discovery failed' }); } } const errors: string[] = []; results.forEach((result: HostDiscoveryResult) => { if (!result.online && result.error) { errors.push(`${result.name}: ${result.error}`); } }); const response: DiscoveryResponse = { hosts: results.map(r => ({ name: r.name, ip: r.ip, online: r.online, containers: r.containers, services: r.services, vms: r.vms, })), timestamp: new Date().toISOString(), errors, }; await cacheAndEmit(response); } catch (error: any) { console.error('Background discovery failed:', error); } } async function cacheAndEmit(response: DiscoveryResponse) { try { // Cache the standard response to the DB await prisma.settings.upsert({ where: { key: 'last_discovery' }, update: { value: JSON.stringify(response) }, create: { key: 'last_discovery', value: JSON.stringify(response) }, }); // Broadcast the full update via Socket.IO io.emit('topology:update', response); } catch (err) { console.error('Failed to cache and emit discovery results:', err); } } export default router;