const http = require('http'); const https = require('https'); const fs = require('fs'); const path = require('path'); const PORT = 3900; const PVE_BASE = 'https://192.168.1.250:8006'; const PORTAINER_BASE = 'https://192.168.1.150:9443'; // Agent HTTPS qui ignore les certificats auto-signés const agent = new https.Agent({ rejectUnauthorized: false }); // --- Helpers --- /** Requête HTTPS avec timeout et gestion d'erreurs */ function fetchJSON(url, headers, timeoutMs = 8000) { return new Promise((resolve, reject) => { const parsed = new URL(url); const req = https.request({ hostname: parsed.hostname, port: parsed.port, path: parsed.pathname + parsed.search, method: 'GET', headers, agent, timeout: timeoutMs, }, (res) => { let body = ''; res.on('data', (chunk) => { body += chunk; }); res.on('end', () => { try { resolve(JSON.parse(body)); } catch { reject(new Error(`Réponse non-JSON de ${url}`)); } }); }); req.on('timeout', () => { req.destroy(); reject(new Error(`Timeout: ${url}`)); }); req.on('error', (err) => reject(err)); req.end(); }); } // --- Proxmox --- /** Récupère la liste des LXC sur tous les nodes Proxmox */ async function getProxmoxLXC() { const token = process.env.PVE_TOKEN; if (!token) return { error: 'PVE_TOKEN non défini' }; const headers = { Authorization: `PVEAPIToken=${token}` }; let nodes; try { const res = await fetchJSON(`${PVE_BASE}/api2/json/nodes`, headers); nodes = res.data; } catch (err) { return { error: `Proxmox injoignable: ${err.message}` }; } const results = []; // Récupérer les LXC de chaque node en parallèle await Promise.all(nodes.map(async (node) => { try { const lxcRes = await fetchJSON(`${PVE_BASE}/api2/json/nodes/${node.node}/lxc`, headers); const lxcList = lxcRes.data || []; // Récupérer les détails de chaque LXC en parallèle const details = await Promise.all(lxcList.map(async (lxc) => { try { const statusRes = await fetchJSON( `${PVE_BASE}/api2/json/nodes/${node.node}/lxc/${lxc.vmid}/status/current`, headers ); const d = statusRes.data || {}; return { vmid: lxc.vmid, name: d.name || lxc.name || `CT ${lxc.vmid}`, node: node.node, status: d.status || lxc.status || 'unknown', cpu: d.cpu || 0, // fraction (0-1) maxcpu: d.cpus || 1, mem: d.mem || 0, // octets utilisés maxmem: d.maxmem || 1, // octets max uptime: d.uptime || 0, }; } catch { // Si le détail échoue, on renvoie les infos de base return { vmid: lxc.vmid, name: lxc.name || `CT ${lxc.vmid}`, node: node.node, status: lxc.status || 'unknown', cpu: 0, maxcpu: 1, mem: 0, maxmem: 1, uptime: 0, }; } })); results.push(...details); } catch { // Node injoignable, on l'ignore } })); // Trier par node puis par vmid results.sort((a, b) => a.node.localeCompare(b.node) || a.vmid - b.vmid); return { containers: results }; } // --- Portainer --- /** Récupère la liste des conteneurs Docker via Portainer */ async function getPortainerContainers() { const apiKey = process.env.PORTAINER_API_KEY; if (!apiKey) return { error: 'PORTAINER_API_KEY non défini' }; const headers = { 'X-API-Key': apiKey }; try { const containers = await fetchJSON( `${PORTAINER_BASE}/api/endpoints/2/docker/containers/json?all=true`, headers ); // Extraire les infos utiles const results = (Array.isArray(containers) ? containers : []).map((c) => { // Le nom Docker commence par "/" — on le retire const name = (c.Names && c.Names[0] || '').replace(/^\//, '') || c.Id?.slice(0, 12); const image = c.Image || ''; const state = c.State || 'unknown'; // running, exited, created, etc. const status = c.Status || ''; // ex: "Up 3 days" // Chercher le label de stack (docker-compose) const labels = c.Labels || {}; const stack = labels['com.docker.compose.project'] || ''; return { name, image, state, status, stack }; }); // Trier : running d'abord, puis par stack, puis par nom results.sort((a, b) => { if (a.state === 'running' && b.state !== 'running') return -1; if (a.state !== 'running' && b.state === 'running') return 1; return (a.stack || '').localeCompare(b.stack || '') || a.name.localeCompare(b.name); }); return { containers: results }; } catch (err) { return { error: `Portainer injoignable: ${err.message}` }; } } // --- Endpoint API --- async function handleAPI(req, res) { const [proxmox, portainer] = await Promise.all([ getProxmoxLXC(), getPortainerContainers(), ]); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ proxmox, portainer, timestamp: Date.now() })); } // --- Serveur de fichiers statiques --- const MIME_TYPES = { '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', '.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', }; function serveStatic(req, res) { let filePath = path.join(__dirname, 'public', req.url === '/' ? 'index.html' : req.url); const ext = path.extname(filePath); const contentType = MIME_TYPES[ext] || 'application/octet-stream'; // Empêcher la traversée de répertoire if (!filePath.startsWith(path.join(__dirname, 'public'))) { res.writeHead(403); res.end('Forbidden'); return; } fs.readFile(filePath, (err, data) => { if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('404 Not Found'); return; } res.writeHead(200, { 'Content-Type': contentType }); res.end(data); }); } // --- Serveur HTTP --- const server = http.createServer(async (req, res) => { if (req.url === '/api/status' && req.method === 'GET') { try { await handleAPI(req, res); } catch (err) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: err.message })); } } else { serveStatic(req, res); } }); server.listen(PORT, '0.0.0.0', () => { console.log(`Dashboard homelab démarré sur http://0.0.0.0:${PORT}`); });