Files
homelab-topology/server/routes/discover.ts
Christopher Mayor 0910c966a5 feat: migrate from static config to database and add authentication
- Replaced static hosts.json and staticConfig.ts with SQLite database (Prisma)

- Implemented JWT authentication and Login UI

- Added dynamic API routes for hosts, topology, and settings

- Updated UI components to fetch and manage state dynamically

- Added Settings interface for managing hosts and topology nodes
2026-02-25 14:07:11 -08:00

219 lines
6.6 KiB
TypeScript

/**
* 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<HostDiscoveryResult> {
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;