// maclustr-www — site vitrine www.maclustr.io (v2, multi-pages) // Serveur Node sans dépendance : fichiers statiques, shell SPA (index.html) pour les routes, // /api/live et /api/history = proxys filtrés vers maclustr-agentd (jamais d'IP, d'utilisateur ni de jeton exposés). // Usage : node server.mjs [port] (env : AGENTD_URL, AGENTD_TOKEN, LIVE_TTL_S) import http from "node:http"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PORT = Number(process.argv[2] || process.env.PORT || 8280); const PUBLIC = path.join(__dirname, "public"); const AGENTD_URL = (process.env.AGENTD_URL || "http://127.0.0.1:9210").replace(/\/$/, ""); const AGENTD_TOKEN = process.env.AGENTD_TOKEN || ""; const TTL_MS = Number(process.env.LIVE_TTL_S || 30) * 1000; const MIME = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", ".webmanifest": "application/manifest+json", ".txt": "text/plain; charset=utf-8", ".woff2": "font/woff2", ".xml": "application/xml", }; // ---------- agentd ---------- async function agentd(pathname) { const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), 12000); try { const r = await fetch(AGENTD_URL + pathname, { headers: { Authorization: `Bearer ${AGENTD_TOKEN}` }, signal: ctl.signal }); if (!r.ok) throw new Error(`agentd ${pathname} → ${r.status}`); return await r.json(); } finally { clearTimeout(t); } } const round = (v, d) => (typeof v === "number" && isFinite(v) ? Number(v.toFixed(d)) : null); function shape(cluster, apps, tunnel) { const nodes = {}; const metrics = cluster?.metrics || {}; for (const n of cluster?.nodes || []) { const m = metrics[n.name] || {}; const online = m.status === "online"; nodes[n.name] = { online, cpu: online ? round(m.cpu, 1) : null, gpu: online ? round(m.gpu, 0) : null, load1: online && Array.isArray(m.load) ? round(m.load[0], 2) : null, memUsedGB: online ? round((m.memUsedMB || 0) / 1024, 1) : null, memTotalGB: online ? round((m.memTotalMB || n.memoryMB || 0) / 1024, 0) : null, swapGB: online ? round((m.swapUsedMB || 0) / 1024, 1) : null, diskUsedGB: online ? round(m.diskUsedGB, 0) : null, diskTotalGB: online ? round(m.diskTotalGB, 0) : null, netInKBs: online ? round(m.netInKBs, 0) : null, netOutKBs: online ? round(m.netOutKBs, 0) : null, procs: online ? m.procs : null, uptimeS: online ? m.uptime : null, os: online ? m.os : null, thermal: online ? m.thermal : null, apps: cluster?.appsByNode?.[n.name] || null, }; } const appOut = {}; const list = Array.isArray(apps?.apps) ? apps.apps : Object.values(apps?.apps || {}); for (const a of list) { appOut[a.app] = { node: a.node, state: a.state, publicOk: a.public?.ok ?? null, publicMs: a.public?.ms ?? null, localOk: a.local?.ok ?? null, localMs: a.local?.ms ?? null, uptime24h: a.uptime24h ?? null, via: a.tunnel?.via || null, memMB: a.memMB ?? null, cpu: a.cpu ?? null, }; } const gws = (tunnel?.gateways || []).map(g => ({ name: g.name, ok: !!g.ok, primary: !!g.primary, site: g.site, peers: (g.wg?.peers || []).map(p => ({ alias: p.alias, online: !!p.online, handshakeS: p.handshakeS ?? null, rx: p.rxBytes ?? 0, tx: p.txBytes ?? 0 })), routes: Array.isArray(g.routes) ? g.routes.filter(r => r.kind === "proxy").length : null, })); const onlineNodes = (cluster?.nodes || []).filter(n => nodes[n.name]?.online); const cpuVals = onlineNodes.map(n => nodes[n.name].cpu).filter(v => v != null); return { ts: Date.now(), agentVersion: cluster?.agentVersion || null, summary: { nodesTotal: Object.keys(nodes).length, nodesOnline: onlineNodes.length, apps: apps?.summary || null, coresOnline: onlineNodes.reduce((s, n) => s + (n.cpuCores || 0), 0), gpuOnline: onlineNodes.reduce((s, n) => s + (n.gpuCores || 0), 0), ramOnlineGB: round(onlineNodes.reduce((s, n) => s + (n.memoryMB || 0) / 1024, 0), 0), ramUsedGB: round(Object.values(nodes).reduce((s, n) => s + (n.memUsedGB || 0), 0), 0), cpuAvg: cpuVals.length ? round(cpuVals.reduce((a, b) => a + b, 0) / cpuVals.length, 1) : null, diskUsedGB: round(Object.values(nodes).reduce((s, n) => s + (n.diskUsedGB || 0), 0), 0), diskTotalGB: round(Object.values(nodes).reduce((s, n) => s + (n.diskTotalGB || 0), 0), 0), netInKBs: round(Object.values(nodes).reduce((s, n) => s + (n.netInKBs || 0), 0), 0), netOutKBs: round(Object.values(nodes).reduce((s, n) => s + (n.netOutKBs || 0), 0), 0), procs: Object.values(nodes).reduce((s, n) => s + (n.procs || 0), 0), tunnelRx: gws.reduce((s, g) => s + g.peers.reduce((a, p) => a + p.rx, 0), 0), tunnelTx: gws.reduce((s, g) => s + g.peers.reduce((a, p) => a + p.tx, 0), 0), }, nodes, apps: appOut, tunnel: gws, }; } // cache générique const caches = new Map(); async function cached(key, ttl, fn) { const c = caches.get(key) || {}; if (c.body && Date.now() - c.ts < ttl) return c.body; if (c.inflight) return c.inflight; c.inflight = (async () => { try { c.body = await fn(); c.ts = Date.now(); return c.body; } catch (e) { if (c.body) return { ...c.body, stale: true, error: String(e.message || e) }; throw e; } finally { c.inflight = null; } })(); caches.set(key, c); return c.inflight; } const live = () => cached("live", TTL_MS, async () => { const [cluster, apps, tunnel] = await Promise.all([agentd("/api/cluster"), agentd("/api/apps"), agentd("/api/tunnel").catch(() => null)]); return shape(cluster, apps, tunnel); }); const history = (node, win) => cached(`hist:${node}:${win}`, 60000, async () => { const d = await agentd(`/api/history?node=${encodeURIComponent(node)}&window=${win}`); return { node, window: win, points: (d.points || []).map(p => ({ ts: p.ts, cpu: round(p.cpu, 1), mem: round(p.mem, 1), gpu: round(p.gpu, 0), netIn: round(p.netIn, 0), netOut: round(p.netOut, 0) })) }; }); // ---------- titres par route (SEO minimal) ---------- const ROUTE_TITLES = [ [/^\/$/, "MacLustr — Le cluster Apple Silicon"], [/^\/nodes\/?$/, "Nœuds — MacLustr"], [/^\/nodes\/([A-Za-z0-9]+)/, (m) => `${m[1]} — nœud MacLustr`], [/^\/apps\/?$/, "Applications hébergées — MacLustr"], [/^\/map\/?$/, "Carte des nœuds — MacLustr"], [/^\/contact\/?$/, "Contact — MacLustr"], ]; let shellCache = null; function shell(pathname) { if (!shellCache) shellCache = fs.readFileSync(path.join(PUBLIC, "index.html"), "utf8"); let title = "MacLustr"; for (const [re, t] of ROUTE_TITLES) { const m = pathname.match(re); if (m) { title = typeof t === "function" ? t(m) : t; break; } } return shellCache.replace(/[^<]*<\/title>/, `<title>${title}`); } if (process.env.NODE_ENV !== "production") fs.watch(PUBLIC, () => { shellCache = null; }); // ---------- http ---------- const server = http.createServer(async (req, res) => { const url = new URL(req.url, "http://x"); res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); try { if (url.pathname === "/healthz") return send(res, 200, "ok\n", "text/plain"); if (url.pathname === "/api/live") { res.setHeader("Cache-Control", "public, max-age=15"); try { return send(res, 200, JSON.stringify(await live()), MIME[".json"]); } catch (e) { return send(res, 503, JSON.stringify({ ts: Date.now(), error: String(e.message || e), nodes: {}, apps: {}, tunnel: [], summary: null }), MIME[".json"]); } } if (url.pathname === "/api/history") { const node = (url.searchParams.get("node") || "").replace(/[^A-Za-z0-9]/g, ""); const win = ["1h", "6h", "24h"].includes(url.searchParams.get("window")) ? url.searchParams.get("window") : "24h"; if (!node) return send(res, 400, JSON.stringify({ error: "node requis" }), MIME[".json"]); res.setHeader("Cache-Control", "public, max-age=60"); try { return send(res, 200, JSON.stringify(await history(node, win)), MIME[".json"]); } catch (e) { return send(res, 503, JSON.stringify({ node, window: win, points: [], error: String(e.message || e) }), MIME[".json"]); } } if (url.pathname === "/robots.txt") return send(res, 200, "User-agent: *\nAllow: /\nSitemap: https://www.maclustr.io/sitemap.xml\n", "text/plain"); if (url.pathname === "/sitemap.xml") { const ids = sitemapIds(); const urls = ["/", "/nodes", "/apps", "/map", "/contact", ...ids.map(i => `/nodes/${i}`)]; return send(res, 200, `\n\n${urls.map(u => ` https://www.maclustr.io${u}`).join("\n")}\n\n`, MIME[".xml"]); } let p = decodeURIComponent(url.pathname); const file = path.normalize(path.join(PUBLIC, p)); if (!file.startsWith(PUBLIC)) return send(res, 403, "forbidden", "text/plain"); const isAsset = path.extname(p) !== ""; if (!isAsset) { res.setHeader("Cache-Control", "no-cache"); return send(res, 200, shell(p), MIME[".html"]); } fs.stat(file, (err, st) => { if (err || !st.isFile()) return send(res, 404, "not found", "text/plain"); const ext = path.extname(file).toLowerCase(); res.setHeader("Cache-Control", ext === ".html" ? "no-cache" : "public, max-age=600"); res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream", "Content-Length": st.size }); fs.createReadStream(file).pipe(res); }); } catch (e) { send(res, 500, "error", "text/plain"); } }); function sitemapIds() { try { const s = fs.readFileSync(path.join(PUBLIC, "data.js"), "utf8"); return [...s.matchAll(/\{ id:"([A-Za-z0-9]+)", group:"(?:lan|rented|ovh)"/g)].map(m => m[1]); } catch { return []; } } function send(res, code, body, type) { res.writeHead(code, { "Content-Type": type }); res.end(body); } server.listen(PORT, "0.0.0.0", () => console.log(`maclustr-www v2 :${PORT} → agentd ${AGENTD_URL} (token ${AGENTD_TOKEN ? "ok" : "ABSENT"})`));