372 lines
13 KiB
JavaScript
372 lines
13 KiB
JavaScript
'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 : 5 * 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();
|
|
});
|