From 8f317641ec3702b1cfbec614380617f30f36081f Mon Sep 17 00:00:00 2001 From: Rob Bond Date: Thu, 21 May 2026 11:36:05 +0100 Subject: [PATCH] fix IP addresses: parse from config/agent endpoints instead of guest agent - LXCs: use /interfaces endpoint first, fallback to net0-net7 config parsing - QEMU: use /agent/network-get-interfaces with correct result nesting - IPv4 only filtering (ip-address-type check) - Drop ip6= and nameserver from config fallback Co-Authored-By: Claude Opus 4.7 --- backlog.md | 7 ++- src/app/api/lxc/route.js | 4 +- src/app/api/vms/route.js | 4 +- src/lib/proxmox.js | 120 ++++++++++++++++++++++++++++++++++----- 4 files changed, 115 insertions(+), 20 deletions(-) diff --git a/backlog.md b/backlog.md index 0c16050..c831bf4 100644 --- a/backlog.md +++ b/backlog.md @@ -28,9 +28,10 @@ ## Quick Wins (low effort, high value) ### Per-VM/LXC IP Addresses -- Fetches guest IPs from `/status/guest` for running VMs/LXCs (requires guest agent). +- LXCs: parses `ip=` and `ip6=` from `net0`, `net1`, etc. fields in `/nodes//lxc//config`. +- VMs: fetches from `/nodes//qemu//agent/network-get-interfaces` and reads `ipaddresses`. - Displays IPs below the name in the table. -- **Status: done (merged to main)** +- **Status: done** ### Search / Filter - Client-side text input filtering table rows by name/node/status @@ -51,7 +52,7 @@ - Note: disk.total is not populated by Proxmox API — only disk.used is available ### Per-VM IP Addresses -- Fetch guest network IP addresses (extra API call needed) +- Done — uses `/nodes//qemu//agent/network-get-interfaces` for VMs and config `net0`/`net1` parsing for LXCs. ### Per-VM CPU/Memory Trend - Periodic polling to build a small trend chart diff --git a/src/app/api/lxc/route.js b/src/app/api/lxc/route.js index 3c7eb41..e3c54bd 100644 --- a/src/app/api/lxc/route.js +++ b/src/app/api/lxc/route.js @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server'; -import { getNodes, getLXC, getGuestIPs } from '@/lib/proxmox'; +import { getNodes, getLXC, getConfigIPs } from '@/lib/proxmox'; const PROXMOX_WEB_URL = process.env.PROXMOX_WEB_URL || process.env.PROXMOX_API_URL?.replace('/api2/json', ''); @@ -16,7 +16,7 @@ export async function GET(_req) { let ips = []; if (lxc.status === 'running') { try { - ips = await getGuestIPs(node.name, lxc.vmid, 'lxc'); + ips = await getConfigIPs(node.name, lxc.vmid, 'lxc'); } catch { /* guest agent not available */ } } allLXC.push({ diff --git a/src/app/api/vms/route.js b/src/app/api/vms/route.js index 6ffe359..26bbe65 100644 --- a/src/app/api/vms/route.js +++ b/src/app/api/vms/route.js @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server'; -import { getNodes, getVMs, getGuestIPs } from '@/lib/proxmox'; +import { getNodes, getVMs, getConfigIPs } from '@/lib/proxmox'; const PROXMOX_WEB_URL = process.env.PROXMOX_WEB_URL || process.env.PROXMOX_API_URL?.replace('/api2/json', ''); @@ -16,7 +16,7 @@ export async function GET(_req) { let ips = []; if (vm.status === 'running') { try { - ips = await getGuestIPs(node.name, vm.vmid, 'qemu'); + ips = await getConfigIPs(node.name, vm.vmid, 'qemu'); } catch { /* guest agent not available */ } } allVMs.push({ diff --git a/src/lib/proxmox.js b/src/lib/proxmox.js index 29d210d..fbd2d41 100644 --- a/src/lib/proxmox.js +++ b/src/lib/proxmox.js @@ -186,23 +186,117 @@ export async function getSummary() { } /** - * Fetch guest IPs from Proxmox. - * Only available when guest agent is running on a live VM/LXC. + * Extract IPs from a Proxmox LXC interfaces endpoint response. + * Response shape: { data: [{ "ip-addresses": [{ "ip-address": "..." }] }] } + * Also handles keyed objects (net0, net1) and /agent/result nesting. + * @param {Object} data — raw response from /nodes//lxc//interfaces + * @returns {string[]} + */ +function parseLXCInterfaceIPs(data) { + const rawIface = data?.data; + const ifaces = Array.isArray(rawIface) + ? rawIface + : (rawIface?.interfaces + ? rawIface.interfaces + : (rawIface?.result ? rawIface.result : rawIface && typeof rawIface === 'object' ? Object.values(rawIface) : [])); + const ips = []; + for (const iface of ifaces) { + const addrs = iface?.ip4_addresses || iface?.ip_addresses || iface?.['ip-addresses'] || []; + for (const addr of addrs) { + if (typeof addr === 'string') { + // raw string — assume IPv4 if it looks like one + if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/.test(addr)) ips.push(addr.split('/')[0]); + continue; + } + // typed entry — only include IPv4 + const type = addr?.['ip-address-type'] || addr?.['ip-address-type']; + if (type && type !== 'inet' && type !== 'ipv4') continue; + const ip = addr?.['ip-address'] || addr?.ip || addr?.address; + if (typeof ip === 'string' && ip !== '127.0.0.1') { + ips.push(ip.split('/')[0]); + } + } + } + return [...new Set(ips)]; +} + +/** + * Extract IPs from a Proxmox LXC config object. + * Parses ip= and ip6= from net0, net1, etc. plus nameserver field. + * @param {Object} config + * @returns {string[]} + */ +function parseLXCIPsFromConfig(config) { + const ips = []; + for (let i = 0; i < 8; i++) { + const field = config[`net${i}`]; + if (!field) continue; + const kv = parseKV(field); + if (kv.ip) ips.push(kv.ip.split('/')[0]); + } + return [...new Set(ips)]; +} + +/** + * Extract IPs from a Proxmox QEMU guest agent network-get-interfaces response. + * Response shape: { data: { result: [{ ip-addresses: [{ "ip-address": "..." }] }] } } + * @param {Object} data — raw response from /agent/network-get-interfaces + * @returns {string[]} + */ +function parseQEMUIPs(data) { + const ifaces = data?.data?.result || data?.data || []; + const ips = []; + for (const iface of ifaces) { + for (const addr of iface?.ip_addresses || iface?.['ip-addresses'] || []) { + if (typeof addr === 'string') { + if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/.test(addr)) ips.push(addr.split('/')[0]); + continue; + } + const type = addr?.['ip-address-type']; + if (type && type !== 'ipv4') continue; + const ip = addr?.['ip-address'] || addr?.ip || addr?.address; + if (typeof ip === 'string' && ip !== '127.0.0.1') { + ips.push(ip.split('/')[0]); + } + } + } + return [...new Set(ips)]; +} + +/** + * Fetch IPs from config (LXC) or guest agent (QEMU). * @param {string} nodeName * @param {number} vmid * @param {'qemu'|'lxc'} type * @returns {Promise} */ -export async function getGuestIPs(nodeName, vmid, type) { - const data = await proxmoxFetch(`/nodes/${nodeName}/${type}/${vmid}/status/guest`); - const ips = data.data?.ipconfig || []; - const addresses = []; - for (const ipcfg of ips) { - for (const addr of ipcfg?.addresses || []) { - if (addr?.ip && addr?.ip !== '127.0.0.1' && addr?.ip !== '::1') { - addresses.push(addr.ip); - } - } +export async function getConfigIPs(nodeName, vmid, type) { + if (type === 'lxc') { + // Try /interfaces endpoint first (more reliable, includes DHCP IPs) + try { + const data = await proxmoxFetch(`/nodes/${nodeName}/lxc/${vmid}/interfaces`); + const ips = parseLXCInterfaceIPs(data); + if (ips.length) return ips; + } catch { /* fallback to config */ } + // Fallback: parse net0/net1 ip= from config + const config = await proxmoxFetch(`/nodes/${nodeName}/lxc/${vmid}/config`); + return parseLXCIPsFromConfig(config.data || config); } - return [...new Set(addresses)]; + // QEMU: use guest agent API + const data = await proxmoxFetch(`/nodes/${nodeName}/qemu/${vmid}/agent/network-get-interfaces`); + return parseQEMUIPs(data); +} + +/** @param {string} s @returns {Object} */ +function parseKV(s) { + const result = {}; + s.split(',').forEach((part) => { + const eq = part.indexOf('='); + if (eq === -1) return; + const k = part.slice(0, eq); + const v = part.slice(eq + 1); + // strip quotes + result[k] = v.startsWith('"') && v.endsWith('"') ? v.slice(1, -1) : v; + }); + return result; }