'use strict'; const express = require('express'); const http = require('http'); const WebSocket = require('ws'); const fetch = require('node-fetch'); const { XMLParser } = require('fast-xml-parser'); const path = require('path'); const fs = require('fs'); const PORT = process.env.DASHBOARD_PORT || 3000; const app = express(); app.use(express.json({ limit: '10mb' })); app.use(express.static(path.join(__dirname, 'public'))); app.use('/sound_effects', express.static(path.resolve('sound_effects'))); const server = http.createServer(app); const wss = new WebSocket.Server({ server }); // Alert state let alertState = { active: false, message: '', html: '', image: '' }; // Broadcast to all connected WS clients function broadcast(data) { const payload = JSON.stringify(data); wss.clients.forEach(client => { if (client.readyState === WebSocket.OPEN) { client.send(payload); } }); } // Send current state to newly connected client wss.on('connection', ws => { if (alertState.active) { ws.send(JSON.stringify({ type: 'alert', message: alertState.message, html: alertState.html, image: alertState.image })); } }); // ── Routes ────────────────────────────────────────────────────────────────── app.get('/alert.mp3', (_req, res) => res.sendFile(path.resolve('alert.mp3'))); app.get('/news.mp3', (_req, res) => res.sendFile(path.resolve('news.mp3'))); app.get('/soft_alarm.mp3', (_req, res) => res.sendFile(path.resolve('soft_alarm.mp3'))); // Proxy: strip X-Frame-Options and CSP, forward any URL app.get('/proxy', async (req, res) => { const { url } = req.query; if (!url) return res.status(400).json({ error: 'Missing url parameter' }); try { const upstream = await fetch(url, { headers: { 'User-Agent': 'CyberDashboard/1.0' }, timeout: 10000 }); const contentType = upstream.headers.get('content-type') || 'text/html'; res.set('Content-Type', contentType); res.removeHeader('X-Frame-Options'); res.removeHeader('Content-Security-Policy'); upstream.body.pipe(res); } catch (err) { res.status(502).json({ error: 'Proxy fetch failed', detail: err.message }); } }); // Alert: GET state app.get('/api/alert', (req, res) => { res.json(alertState); }); // Alert: POST (trigger) app.post('/api/alert', (req, res) => { const { message = '', html = '', image = '' } = req.body; alertState = { active: true, message, html, image }; broadcast({ type: 'alert', message, html, image }); res.json({ ok: true }); }); // Alert: DELETE (dismiss) app.delete('/api/alert', (req, res) => { alertState = { active: false, message: '', html: '', image: '' }; broadcast({ type: 'dismiss' }); res.json({ ok: true }); }); // ── Feed pollers (détection server-side, broadcast WS) ────────────────────── const FEED_POLL_MS = 5 * 60 * 1000; // ANSSI let anssiCache = null; let seenAnssiLinks = null; async function pollAnssi() { try { const response = await fetch('https://www.cert.ssi.gouv.fr/feed/', { headers: { 'User-Agent': 'CyberDashboard/1.0' }, timeout: 10000 }); const xml = await response.text(); const parser = new XMLParser({ ignoreAttributes: false }); const items = parser.parse(xml)?.rss?.channel?.item || []; const entries = (Array.isArray(items) ? items : [items]) .map(item => ({ title: item.title || '', link: item.link || '', pubDate: item.pubDate || '', description: item.description || '' })) .sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate)) .slice(0, 7); anssiCache = entries; const currentLinks = new Set(entries.map(i => i.link)); if (seenAnssiLinks === null) { seenAnssiLinks = currentLinks; } else { const newItems = entries.filter(i => !seenAnssiLinks.has(i.link)); if (newItems.length) { broadcast({ type: 'anssi_news', title: newItems[0].title, link: newItems[0].link }); seenAnssiLinks = currentLinks; } } } catch (err) { console.error('[anssi] poll error:', err.message); } } app.get('/api/feeds/anssi', async (req, res) => { if (anssiCache) return res.json(anssiCache); // Premier appel avant le premier poll await pollAnssi(); res.json(anssiCache || []); }); // Géopolitique — Google News RSS let geoCache = null; let seenGeoLinks = null; const GEO_QUERY_URL = (() => { const q = encodeURIComponent( 'Ukraine OR Russie OR Iran OR "Moyen-Orient" OR OTAN OR guerre OR conflit' + ' OR cyberattaque OR ransomware OR APT OR "zero-day" OR vulnérabilité OR hack OR malware OR breach' ); return `https://news.google.com/rss/search?q=${q}&hl=fr&gl=FR&ceid=FR:fr`; })(); async function pollGeo() { try { const response = await fetch(GEO_QUERY_URL, { headers: { 'User-Agent': 'CyberDashboard/1.0' }, timeout: 10000 }); const xml = await response.text(); const parser = new XMLParser({ ignoreAttributes: false }); const items = parser.parse(xml)?.rss?.channel?.item || []; const entries = (Array.isArray(items) ? items : [items]) .map(item => ({ title: item.title || '', link: item.link || '', pubDate: item.pubDate || '', source: item.source?.['#text'] || item.source || '' })) .sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate)) .slice(0, 7); geoCache = entries; const currentLinks = new Set(entries.map(i => i.link)); if (seenGeoLinks === null) { seenGeoLinks = currentLinks; } else { const newItems = entries.filter(i => !seenGeoLinks.has(i.link)); if (newItems.length) { broadcast({ type: 'geo_news', title: newItems[0].title, link: newItems[0].link }); seenGeoLinks = currentLinks; } } } catch (err) { console.error('[geo] poll error:', err.message); } } app.get('/api/feeds/geo', async (req, res) => { if (geoCache) return res.json(geoCache); await pollGeo(); res.json(geoCache || []); }); // ── ICS / Calendar ────────────────────────────────────────────────────────── const CALENDAR_URL = 'https://zeus.ionis-it.com/api/group/22/ics/TwBFCoxyY3?startDate=2026-01-01'; const CALENDAR_CACHE_TTL = 30 * 60 * 1000; let calendarCache = null; let calendarCacheTime = 0; function parseICS(text) { const unfolded = text.replace(/\r\n[ \t]/g, '').replace(/\n[ \t]/g, ''); const lines = unfolded.split(/\r?\n/); const events = []; let cur = null; for (const line of lines) { if (line === 'BEGIN:VEVENT') { cur = {}; } else if (line === 'END:VEVENT' && cur) { events.push(cur); cur = null; } else if (cur) { const ci = line.indexOf(':'); if (ci === -1) continue; cur[line.slice(0, ci).split(';')[0]] = line.slice(ci + 1); } } return events; } function parseICSDate(str) { if (!str) return null; const utc = str.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/); if (utc) return new Date(`${utc[1]}-${utc[2]}-${utc[3]}T${utc[4]}:${utc[5]}:${utc[6]}Z`); const loc = str.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$/); if (loc) return new Date(`${loc[1]}-${loc[2]}-${loc[3]}T${loc[4]}:${loc[5]}:${loc[6]}`); const day = str.match(/^(\d{4})(\d{2})(\d{2})$/); if (day) return new Date(`${day[1]}-${day[2]}-${day[3]}`); return null; } app.get('/api/calendar', async (req, res) => { if (calendarCache && Date.now() - calendarCacheTime < CALENDAR_CACHE_TTL) { return res.json(calendarCache); } try { const response = await fetch(CALENDAR_URL, { headers: { 'User-Agent': 'CyberDashboard/1.0' }, timeout: 10000 }); const raw = parseICS(await response.text()); const now = new Date(); const weekStart = new Date(now); weekStart.setHours(0, 0, 0, 0); const d = weekStart.getDay(); weekStart.setDate(weekStart.getDate() - (d === 0 ? 6 : d - 1)); const weekEnd = new Date(weekStart); weekEnd.setDate(weekStart.getDate() + 7); const unescape = s => (s || '').replace(/\\,/g, ',').replace(/\\n/g, ' ').replace(/\\;/g, ';').trim(); const events = raw .map(e => { const start = parseICSDate(e['DTSTART']); const end = parseICSDate(e['DTEND']); if (!start || !end) return null; return { title: unescape(e['SUMMARY']), location: unescape(e['LOCATION']), start: start.toISOString(), end: end.toISOString() }; }) .filter(e => e && new Date(e.start) >= weekStart && new Date(e.start) < weekEnd) .sort((a, b) => new Date(a.start) - new Date(b.start)); calendarCache = events; calendarCacheTime = Date.now(); res.json(events); } catch (err) { res.status(502).json({ error: 'Calendar fetch failed', detail: err.message }); } }); // Root-me ranking // Polling rotatif : on poll un joueur à la fois en rotation continue. // Avec N joueurs et un intervalle cible de 2 min : délai entre chaque = 2min / N. const ROOTME_TARGET_INTERVAL_MS = 2 * 60 * 1000; // refresh cible par joueur const ROOTME_MIN_DELAY_MS = 10_000; // plancher entre deux requêtes let rootmeCache = null; let rootmePrevScores = {}; // login → last known score const rootmePlayerCache = {}; // id → { login, score, rank } // Killstreak : streak par joueur, réinitialisée quand un autre joueur flag let killstreakLastFlagger = null; const killstreakCounts = {}; // login → streak actuel function recordFlag(login) { if (killstreakLastFlagger !== login) { killstreakCounts[login] = 1; } else { killstreakCounts[login] = (killstreakCounts[login] || 0) + 1; } killstreakLastFlagger = login; return killstreakCounts[login]; } function parseRootmeUser(profile, id) { const profileRaw = Array.isArray(profile) ? profile[0] : profile; const user = profileRaw?.['0'] ?? profileRaw; if (!user || user.error) return null; return { login: user.nom || id, score: Number(user.score) || 0, rank: user.position || null }; } function startRootmePoller() { const apiKey = process.env.ROOTME_API_KEY; if (!apiKey) return; let ids; try { ids = fs.readFileSync(path.resolve('logins.txt'), 'utf8') .split('\n').map(l => l.trim()).filter(Boolean); } catch (err) { console.error('[rootme] cannot read logins.txt:', err.message); return; } if (!ids.length) return; const delayMs = Math.max(ROOTME_MIN_DELAY_MS, Math.floor(ROOTME_TARGET_INTERVAL_MS / ids.length)); const headers = { 'Cookie': `api_key=${apiKey}`, 'User-Agent': 'CyberDashboard/1.0' }; rootmeCache = []; let idx = 0; let backoffUntil = 0; // timestamp jusqu'auquel on suspend le polling async function pollNext() { const id = ids[idx]; idx = (idx + 1) % ids.length; const waitMs = Math.max(delayMs, backoffUntil - Date.now()); if (waitMs > delayMs) { console.log(`[rootme] backoff actif, reprise dans ${Math.round(waitMs / 1000)}s`); return setTimeout(pollNext, waitMs); } try { const resp = await fetch(`https://api.www.root-me.org/auteurs/${id}`, { headers, timeout: 10000 }); if (resp.status === 429) { const retryAfterRaw = resp.headers.get('retry-after'); const retryAfter = parseInt(retryAfterRaw || '0', 10); const pauseMs = (retryAfter > 0 ? retryAfter * 1000 : 10 * 60 * 1000); backoffUntil = Date.now() + pauseMs; const retryAfterMsg = retryAfterRaw ? `Retry-After: ${retryAfterRaw}s` : 'Retry-After: absent (défaut 5 min)'; console.warn(`[rootme] 429 pour id "${id}" — ${retryAfterMsg} — pause ${Math.round(pauseMs / 1000)}s`); } else { const entry = parseRootmeUser(await resp.json(), id); if (entry) { rootmePlayerCache[id] = entry; const prev = rootmePrevScores[entry.login]; if (prev !== undefined && entry.score > prev) { const gained = entry.score - prev; const streak = recordFlag(entry.login); console.log(`[rootme] FLAG ! ${entry.login} +${gained} pts (${prev} → ${entry.score}) streak=${streak}`); broadcast({ type: 'rootme_flag', login: entry.login, gained, newScore: entry.score, streak }); } rootmePrevScores[entry.login] = entry.score; const i = rootmeCache.findIndex(u => u.login === entry.login); if (i !== -1) rootmeCache[i] = entry; else rootmeCache.push(entry); rootmeCache.sort((a, b) => b.score - a.score); broadcast({ type: 'rootme_update', ranking: rootmeCache }); } } } catch (err) { console.error(`[rootme] erreur pour id "${id}":`, err.message); } setTimeout(pollNext, delayMs); } console.log(`[rootme] démarrage polling rotatif — ${ids.length} joueur(s), 1 requête toutes les ${delayMs / 1000}s → refresh ~${Math.round(ROOTME_TARGET_INTERVAL_MS / 60000)} min/joueur`); pollNext(); } app.get('/api/rootme', (req, res) => { if (!process.env.ROOTME_API_KEY) return res.status(503).json({ error: 'ROOTME_API_KEY not configured' }); if (!rootmeCache) return res.json([]); res.json(rootmeCache); }); server.listen(PORT, () => { console.log(`Cyber Dashboard running on http://localhost:${PORT}`); pollAnssi(); setInterval(pollAnssi, FEED_POLL_MS); pollGeo(); setInterval(pollGeo, FEED_POLL_MS); startRootmePoller(); });