/** * Machine Detector * Auto-detects machine information, either over SSH (remote machines) or * locally (the management box adding itself — 127.0.0.1, no SSH needed). * * The detection logic is identical either way; only command EXECUTION * differs. Each detector takes a `CommandRunner` so the SSH and local * paths share the same parsing. */ import { execSync } from 'node:child_process'; import type { NetworkZone } from '../db/schema'; import type { DetectedMachineInfo, MachineRole, NetworkInterface } from '../types/infrastructure'; import { detectZoneFromIp } from './zone-detector'; /** * Detection error */ export class DetectionError extends Error { constructor(message: string) { super(message); this.name = 'DetectionError'; } } /** Runs a shell command and returns trimmed stdout (throws on failure). */ export type CommandRunner = (command: string) => string; /** * Per-command SSH timeout. Detection opens a fresh SSH connection per * attribute (hostname, cpu, memory, ...); under load (e.g. the e2e builder * running many containers) a single handshake + command can exceed a tight * budget, surfacing as `spawnSync ... ETIMEDOUT` and failing an * otherwise-healthy `machine add`. 30s gives headroom without masking a * genuinely-unreachable host (which fails fast on connection refusal). */ const SSH_TIMEOUT_MS = 30_000; /** Retry attempts for a detection SSH command. Detection is read-only * (hostname / nproc / free / ...), so retrying a transient timeout is safe. */ const SSH_ATTEMPTS = 3; /** Synchronous sleep — detection runs synchronously (execSync), so there's no * async context to await in. */ function sleepSync(ms: number): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } /** * Execute SSH command and return output. Retries transient failures * (connection timeouts under load) with linear backoff — safe because every * detection command is read-only. */ function sshExec(ip: string, user: string, keyPath: string, command: string): string { const sshCmd = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=10 -i "${keyPath}" ${user}@${ip} "${command}"`; let lastMessage = 'Unknown error'; for (let attempt = 1; attempt <= SSH_ATTEMPTS; attempt++) { try { return execSync(sshCmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: SSH_TIMEOUT_MS, }).trim(); } catch (error) { lastMessage = error instanceof Error ? error.message : 'Unknown error'; if (attempt < SSH_ATTEMPTS) sleepSync(1000 * attempt); } } throw new DetectionError(`SSH command failed after ${SSH_ATTEMPTS} attempts: ${lastMessage}`); } /** CommandRunner that SSHes to a remote machine. */ function sshRunner(ip: string, user: string, keyPath: string): CommandRunner { return (command) => sshExec(ip, user, keyPath, command); } /** CommandRunner that runs the command locally (for the self/localhost box). */ export const localRunner: CommandRunner = (command) => { try { return execSync(command, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000, shell: '/bin/bash', }).trim(); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; throw new DetectionError(`Local command failed: ${message}`); } }; /** * Detect hostname */ function detectHostname(run: CommandRunner): string { const output = run('hostname'); if (!output) { throw new DetectionError('Failed to detect hostname: empty output'); } return output; } /** * Detect CPU cores */ function detectCpuCores(run: CommandRunner): number { const output = run('nproc || grep -c ^processor /proc/cpuinfo'); const cores = Number.parseInt(output, 10); if (Number.isNaN(cores) || cores <= 0) { throw new DetectionError(`Invalid CPU cores detected: ${output}`); } return cores; } /** * Detect memory in MB */ function detectMemory(run: CommandRunner): number { // Get memory line, parse in JavaScript const output = run('grep MemTotal /proc/meminfo'); // Parse "MemTotal: 1048576 kB" -> extract number const match = output.match(/MemTotal:\s+(\d+)\s+kB/); if (!match) { throw new DetectionError(`Invalid memory format: ${output}`); } const memoryKb = Number.parseInt(match[1], 10); if (Number.isNaN(memoryKb) || memoryKb <= 0) { throw new DetectionError(`Invalid memory value: ${match[1]}`); } return Math.floor(memoryKb / 1024); } /** * Detect disk space in GB */ function detectDisk(run: CommandRunner): number { // Get root filesystem size, parse in JavaScript const output = run('df -BG / | tail -1'); // Parse "Filesystem 1G-blocks Used Available Use% Mounted" -> extract second field // Example: "/dev/sda1 20G 5G 14G 27% /" const parts = output.split(/\s+/).filter((part) => part.length > 0); if (parts.length < 2) { throw new DetectionError(`Invalid df output format: ${output}`); } // Second field should be size in format like "20G" const sizeStr = parts[1]; const match = sizeStr.match(/^(\d+)G$/); if (!match) { throw new DetectionError(`Invalid disk size format: ${sizeStr}`); } const diskGb = Number.parseInt(match[1], 10); if (Number.isNaN(diskGb) || diskGb <= 0) { throw new DetectionError(`Invalid disk size value: ${match[1]}`); } return diskGb; } /** * Detect CPU architecture (arm64, x64, etc.) */ function detectArch(run: CommandRunner): string { try { const output = run('dpkg --print-architecture 2>/dev/null || uname -m'); const raw = output.trim(); // Normalize: aarch64 → arm64, x86_64 → amd64 if (raw === 'aarch64' || raw === 'arm64') return 'arm64'; if (raw === 'x86_64' || raw === 'amd64') return 'amd64'; return raw; } catch { return 'unknown'; } } /** * Detect OS information */ function detectOsInfo(run: CommandRunner): string { try { // Try /etc/os-release first (most modern systems) const output = run("cat /etc/os-release | grep PRETTY_NAME | cut -d'\"' -f2"); if (output) { return output; } } catch { // Fall back to uname if /etc/os-release not available try { const output = run('uname -s -r'); return output || 'Unknown Linux'; } catch { return 'Unknown Linux'; } } return 'Unknown Linux'; } /** * Parse `ip -j addr show` JSON output into NetworkInterface list */ function parseIpJsonOutput(output: string): Array<{ name: string; ipAddress: string }> { const interfaces: Array<{ name: string; ipAddress: string }> = []; const parsed = JSON.parse(output); for (const iface of parsed) { if (iface.ifname === 'lo') continue; const addrInfo = iface.addr_info; if (!Array.isArray(addrInfo)) continue; for (const addr of addrInfo) { if (addr.family === 'inet' && addr.local) { interfaces.push({ name: iface.ifname, ipAddress: addr.local }); } } } return interfaces; } /** * Parse text `ip addr show` output as fallback */ function parseIpTextOutput(output: string): Array<{ name: string; ipAddress: string }> { const interfaces: Array<{ name: string; ipAddress: string }> = []; let currentIface = ''; for (const line of output.split('\n')) { // Interface line: "2: eth0: { let rawInterfaces: Array<{ name: string; ipAddress: string }>; try { const jsonOutput = run('ip -j addr show'); rawInterfaces = parseIpJsonOutput(jsonOutput); } catch { try { const textOutput = run('ip addr show'); rawInterfaces = parseIpTextOutput(textOutput); } catch { // Can't detect interfaces - return single interface from known IP const zone = await detectZoneFromIp(fallbackIp); return { interfaces: [{ name: 'unknown', ipAddress: fallbackIp, zone }], role: 'host', }; } } if (rawInterfaces.length === 0) { const zone = await detectZoneFromIp(fallbackIp); return { interfaces: [{ name: 'unknown', ipAddress: fallbackIp, zone }], role: 'host', }; } // Filter out virtual/container interfaces — these are not real network // interfaces and should not affect router classification. // docker0, veth*, br-* are Docker artifacts; virbr* is libvirt. const physicalInterfaces = rawInterfaces.filter( (iface) => !iface.name.startsWith('docker') && !iface.name.startsWith('veth') && !iface.name.startsWith('br-') && !iface.name.startsWith('virbr'), ); // Match each interface IP to a zone const interfaces: NetworkInterface[] = []; for (const iface of physicalInterfaces) { const zone: NetworkZone | 'unknown' = await detectZoneFromIp(iface.ipAddress); interfaces.push({ name: iface.name, ipAddress: iface.ipAddress, zone }); } // Classify: router if interfaces span multiple distinct zones. // // The `!== 'unknown'` filter was dead code until now — `detectZoneFromIp` // could not produce `'unknown'`, it claimed `external` instead, so every // unmatched leg counted as a distinct zone and inflated this set. On the e2e // firewall that meant four legs reported as `external`, collapsing to ONE // zone here rather than the three real ones. The filter is live now, and it // is the right rule: an interface celilo cannot attribute is not evidence of // spanning anything. const uniqueZones = new Set(interfaces.map((i) => i.zone).filter((z) => z !== 'unknown')); const role: MachineRole = uniqueZones.size > 1 ? 'router' : 'host'; return { interfaces, role }; } /** * Detect all network interfaces on a remote machine via SSH. */ export async function detectNetworkInterfaces( ip: string, sshUser: string, sshKeyPath: string, ): Promise<{ interfaces: NetworkInterface[]; role: MachineRole }> { return detectNetworkInterfacesWith(sshRunner(ip, sshUser, sshKeyPath), ip); } /** * Detect network interfaces on the local (management) box — no SSH. */ export async function detectNetworkInterfacesLocal(): Promise<{ interfaces: NetworkInterface[]; role: MachineRole; }> { return detectNetworkInterfacesWith(localRunner, '127.0.0.1'); } /** * Detect machine information using the given runner. */ function detectMachineInfoWith(run: CommandRunner): DetectedMachineInfo { const hostname = detectHostname(run); const cpu_cores = detectCpuCores(run); const memory_mb = detectMemory(run); const disk_gb = detectDisk(run); const arch = detectArch(run); const osInfo = detectOsInfo(run); return { hostname, osInfo, hardware: { cpu_cores, memory_mb, disk_gb, arch, }, }; } /** * Detect machine information via SSH * * @param ip - Machine IP address * @param sshUser - SSH username * @param sshKeyPath - Path to SSH private key file * @returns Detected machine information * @throws DetectionError if detection fails */ export async function detectMachineInfo( ip: string, sshUser: string, sshKeyPath: string, ): Promise { // Validate inputs if (!ip) { throw new DetectionError('IP address is required'); } if (!sshUser) { throw new DetectionError('SSH user is required'); } if (!sshKeyPath) { throw new DetectionError('SSH key path is required'); } return detectMachineInfoWith(sshRunner(ip, sshUser, sshKeyPath)); } /** * Detect machine information on the local (management) box — no SSH. */ export async function detectMachineInfoLocal(): Promise { return detectMachineInfoWith(localRunner); } /** * Test SSH connectivity to a machine * * @param ip - Machine IP address * @param sshUser - SSH username * @param sshKeyPath - Path to SSH private key file * @returns True if SSH connection successful */ export async function testSshConnection( ip: string, sshUser: string, sshKeyPath: string, ): Promise { try { sshExec(ip, sshUser, sshKeyPath, 'echo test'); return true; } catch { return false; } } /** * How an interface's zone reads to an operator. * * `machine add` used to print the raw zone for every leg, which meant a firewall * with five RFC1918 interfaces printed four of them as `(external)` — because * `detectZoneFromIp` answered `external` when it meant "no idea". The vocabulary * now distinguishes the two: a zone name when celilo matched one, and * `unaccounted for` when it did not, which is the honest answer and the one that * tells an operator there is something to declare. */ export function describeInterfaceZone(iface: NetworkInterface): string { return iface.zone === 'unknown' ? 'unaccounted for — no declared subnet contains it' : iface.zone; }