Compare commits
2 Commits
0529f2e4c9
...
04cdd47521
| Author | SHA1 | Date |
|---|---|---|
|
|
04cdd47521 | |
|
|
86838b0fb8 |
|
|
@ -28,6 +28,10 @@ function connectWS() {
|
||||||
} else if (msg.type === 'rootme_flag') {
|
} else if (msg.type === 'rootme_flag') {
|
||||||
renderRootme(rootmeCache);
|
renderRootme(rootmeCache);
|
||||||
showNotif(`FLAG ! ${msg.login} +${msg.gained} PTS — TOTAL : ${msg.newScore} PTS`);
|
showNotif(`FLAG ! ${msg.login} +${msg.gained} PTS — TOTAL : ${msg.newScore} PTS`);
|
||||||
|
} else if (msg.type === 'geo_news') {
|
||||||
|
showGeoBanner(msg.title, msg.link);
|
||||||
|
} else if (msg.type === 'anssi_news') {
|
||||||
|
showNotif(`Nouveau bulletin ANSSI : ${msg.title}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -203,7 +207,6 @@ function updateClock() {
|
||||||
|
|
||||||
const anssiList = document.getElementById('anssi-list');
|
const anssiList = document.getElementById('anssi-list');
|
||||||
const anssiStatus = document.getElementById('anssi-status');
|
const anssiStatus = document.getElementById('anssi-status');
|
||||||
let seenAnssiLinks = null;
|
|
||||||
|
|
||||||
async function loadAnssi() {
|
async function loadAnssi() {
|
||||||
anssiStatus.textContent = '...';
|
anssiStatus.textContent = '...';
|
||||||
|
|
@ -219,17 +222,6 @@ async function loadAnssi() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentAnssiLinks = new Set(items.map(i => i.link));
|
|
||||||
if (seenAnssiLinks === null) {
|
|
||||||
seenAnssiLinks = currentAnssiLinks;
|
|
||||||
} else {
|
|
||||||
const newItems = items.filter(i => !seenAnssiLinks.has(i.link));
|
|
||||||
if (newItems.length) {
|
|
||||||
showNotif(`Nouveau bulletin ANSSI : ${newItems[0].title}`);
|
|
||||||
seenAnssiLinks = currentAnssiLinks;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.className = 'anssi-item';
|
li.className = 'anssi-item';
|
||||||
|
|
@ -258,7 +250,6 @@ async function loadAnssi() {
|
||||||
|
|
||||||
const geoList = document.getElementById('geo-list');
|
const geoList = document.getElementById('geo-list');
|
||||||
const geoStatus = document.getElementById('geo-status');
|
const geoStatus = document.getElementById('geo-status');
|
||||||
let seenGeoLinks = null;
|
|
||||||
|
|
||||||
async function loadGeo() {
|
async function loadGeo() {
|
||||||
geoStatus.textContent = '...';
|
geoStatus.textContent = '...';
|
||||||
|
|
@ -274,19 +265,6 @@ async function loadGeo() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentLinks = new Set(items.map(i => i.link));
|
|
||||||
|
|
||||||
if (seenGeoLinks === null) {
|
|
||||||
// Premier chargement : on mémorise sans jouer
|
|
||||||
seenGeoLinks = currentLinks;
|
|
||||||
} else {
|
|
||||||
const newGeoItems = items.filter(i => !seenGeoLinks.has(i.link));
|
|
||||||
if (newGeoItems.length) {
|
|
||||||
showGeoBanner(newGeoItems[0].title, newGeoItems[0].link);
|
|
||||||
seenGeoLinks = currentLinks;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.className = 'anssi-item';
|
li.className = 'anssi-item';
|
||||||
|
|
|
||||||
265
server.js
265
server.js
|
|
@ -88,61 +88,95 @@ app.delete('/api/alert', (req, res) => {
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ANSSI / CERT-FR RSS feed
|
// ── Feed pollers (détection server-side, broadcast WS) ──────────────────────
|
||||||
app.get('/api/feeds/anssi', async (req, res) => {
|
|
||||||
|
const FEED_POLL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// ANSSI
|
||||||
|
let anssiCache = null;
|
||||||
|
let seenAnssiLinks = null;
|
||||||
|
|
||||||
|
async function pollAnssi() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('https://www.cert.ssi.gouv.fr/feed/', {
|
const response = await fetch('https://www.cert.ssi.gouv.fr/feed/', {
|
||||||
headers: { 'User-Agent': 'CyberDashboard/1.0' },
|
headers: { 'User-Agent': 'CyberDashboard/1.0' }, timeout: 10000
|
||||||
timeout: 10000
|
|
||||||
});
|
});
|
||||||
const xml = await response.text();
|
const xml = await response.text();
|
||||||
const parser = new XMLParser({ ignoreAttributes: false });
|
const parser = new XMLParser({ ignoreAttributes: false });
|
||||||
const parsed = parser.parse(xml);
|
const items = parser.parse(xml)?.rss?.channel?.item || [];
|
||||||
const items = parsed?.rss?.channel?.item || [];
|
|
||||||
const entries = (Array.isArray(items) ? items : [items])
|
const entries = (Array.isArray(items) ? items : [items])
|
||||||
.map(item => ({
|
.map(item => ({ title: item.title || '', link: item.link || '', pubDate: item.pubDate || '', description: item.description || '' }))
|
||||||
title: item.title || '',
|
|
||||||
link: item.link || '',
|
|
||||||
pubDate: item.pubDate || '',
|
|
||||||
description: item.description || ''
|
|
||||||
}))
|
|
||||||
.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate))
|
.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate))
|
||||||
.slice(0, 7);
|
.slice(0, 7);
|
||||||
res.json(entries);
|
|
||||||
|
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) {
|
} catch (err) {
|
||||||
res.status(502).json({ error: 'Feed fetch failed', detail: err.message });
|
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 (conflits, cyberattaques, Ukraine, Iran…)
|
// Géopolitique — Google News RSS
|
||||||
app.get('/api/feeds/geo', async (req, res) => {
|
let geoCache = null;
|
||||||
const query = encodeURIComponent(
|
let seenGeoLinks = null;
|
||||||
|
|
||||||
|
const GEO_QUERY_URL = (() => {
|
||||||
|
const q = encodeURIComponent(
|
||||||
'Ukraine OR Russie OR Iran OR "Moyen-Orient" OR OTAN OR guerre OR conflit' +
|
'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'
|
' OR cyberattaque OR ransomware OR APT OR "zero-day" OR vulnérabilité OR hack OR malware OR breach'
|
||||||
);
|
);
|
||||||
const url = `https://news.google.com/rss/search?q=${query}&hl=fr&gl=FR&ceid=FR:fr`;
|
return `https://news.google.com/rss/search?q=${q}&hl=fr&gl=FR&ceid=FR:fr`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
async function pollGeo() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(GEO_QUERY_URL, {
|
||||||
headers: { 'User-Agent': 'CyberDashboard/1.0' },
|
headers: { 'User-Agent': 'CyberDashboard/1.0' }, timeout: 10000
|
||||||
timeout: 10000
|
|
||||||
});
|
});
|
||||||
const xml = await response.text();
|
const xml = await response.text();
|
||||||
const parser = new XMLParser({ ignoreAttributes: false });
|
const parser = new XMLParser({ ignoreAttributes: false });
|
||||||
const parsed = parser.parse(xml);
|
const items = parser.parse(xml)?.rss?.channel?.item || [];
|
||||||
const items = parsed?.rss?.channel?.item || [];
|
|
||||||
const entries = (Array.isArray(items) ? items : [items])
|
const entries = (Array.isArray(items) ? items : [items])
|
||||||
.map(item => ({
|
.map(item => ({ title: item.title || '', link: item.link || '', pubDate: item.pubDate || '', source: item.source?.['#text'] || item.source || '' }))
|
||||||
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))
|
.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate))
|
||||||
.slice(0, 7);
|
.slice(0, 7);
|
||||||
res.json(entries);
|
|
||||||
|
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) {
|
} catch (err) {
|
||||||
res.status(502).json({ error: 'Geo feed fetch failed', detail: err.message });
|
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 ──────────────────────────────────────────────────────────
|
// ── ICS / Calendar ──────────────────────────────────────────────────────────
|
||||||
|
|
@ -220,17 +254,14 @@ app.get('/api/calendar', async (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Root-me ranking
|
// Root-me ranking
|
||||||
const ROOTME_POLL_MS = 10 * 60 * 1000;
|
// 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 anti-429
|
||||||
|
|
||||||
let rootmeCache = null;
|
let rootmeCache = null;
|
||||||
let rootmePrevScores = {}; // login → last known score
|
let rootmePrevScores = {}; // login → last known score
|
||||||
|
|
||||||
const ROOTME_REQUEST_DELAY_MS = 500;
|
|
||||||
const ROOTME_RETRY_BASE_MS = 2 * 60 * 1000; // 2 min, doublé à chaque échec
|
|
||||||
const ROOTME_RETRY_MAX = 3;
|
|
||||||
const rootmePlayerCache = {}; // id → { login, score, rank }
|
const rootmePlayerCache = {}; // id → { login, score, rank }
|
||||||
const retryQueue = new Map(); // id → { attempts, nextRetry }
|
|
||||||
|
|
||||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
function parseRootmeUser(profile, id) {
|
function parseRootmeUser(profile, id) {
|
||||||
const profileRaw = Array.isArray(profile) ? profile[0] : profile;
|
const profileRaw = Array.isArray(profile) ? profile[0] : profile;
|
||||||
|
|
@ -239,117 +270,61 @@ function parseRootmeUser(profile, id) {
|
||||||
return { login: user.nom || id, score: Number(user.score) || 0, rank: user.position || null };
|
return { login: user.nom || id, score: Number(user.score) || 0, rank: user.position || null };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRootmeRanking(apiKey) {
|
function startRootmePoller() {
|
||||||
const raw = fs.readFileSync(path.resolve('logins.txt'), 'utf8');
|
|
||||||
const ids = raw.split('\n').map(l => l.trim()).filter(Boolean);
|
|
||||||
const headers = { 'Cookie': `api_key=${apiKey}`, 'User-Agent': 'CyberDashboard/1.0' };
|
|
||||||
|
|
||||||
const results = [];
|
|
||||||
for (const id of ids) {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(
|
|
||||||
`https://api.www.root-me.org/auteurs/${id}`,
|
|
||||||
{ headers, timeout: 10000 }
|
|
||||||
);
|
|
||||||
if (resp.status === 429) {
|
|
||||||
console.warn(`[rootme] rate-limited on id "${id}", scheduling retry`);
|
|
||||||
if (rootmePlayerCache[id]) results.push(rootmePlayerCache[id]);
|
|
||||||
retryQueue.set(id, { attempts: 1, nextRetry: Date.now() + ROOTME_RETRY_BASE_MS });
|
|
||||||
} else {
|
|
||||||
const entry = parseRootmeUser(await resp.json(), id);
|
|
||||||
if (entry) { rootmePlayerCache[id] = entry; results.push(entry); }
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[rootme] fetch error for id "${id}":`, err.message);
|
|
||||||
if (rootmePlayerCache[id]) results.push(rootmePlayerCache[id]);
|
|
||||||
}
|
|
||||||
await sleep(ROOTME_REQUEST_DELAY_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
return results.sort((a, b) => b.score - a.score);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function retryRateLimited() {
|
|
||||||
const apiKey = process.env.ROOTME_API_KEY;
|
|
||||||
if (!apiKey || retryQueue.size === 0) return;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const headers = { 'Cookie': `api_key=${apiKey}`, 'User-Agent': 'CyberDashboard/1.0' };
|
|
||||||
|
|
||||||
for (const [id, state] of retryQueue) {
|
|
||||||
if (now < state.nextRetry) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch(
|
|
||||||
`https://api.www.root-me.org/auteurs/${id}`,
|
|
||||||
{ headers, timeout: 10000 }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (resp.status === 429) {
|
|
||||||
if (state.attempts >= ROOTME_RETRY_MAX) {
|
|
||||||
console.warn(`[rootme] retry exhausted for id "${id}", giving up until next poll`);
|
|
||||||
retryQueue.delete(id);
|
|
||||||
} else {
|
|
||||||
state.attempts++;
|
|
||||||
state.nextRetry = Date.now() + ROOTME_RETRY_BASE_MS * Math.pow(2, state.attempts - 1);
|
|
||||||
console.warn(`[rootme] retry 429 for id "${id}" (attempt ${state.attempts}/${ROOTME_RETRY_MAX}), next in ${Math.round((state.nextRetry - Date.now()) / 60000)} min`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const entry = parseRootmeUser(await resp.json(), id);
|
|
||||||
if (entry) {
|
|
||||||
const prev = rootmePrevScores[entry.login];
|
|
||||||
if (prev !== undefined && entry.score > prev) {
|
|
||||||
const gained = entry.score - prev;
|
|
||||||
console.log(`[rootme] FLAG (retry) ! ${entry.login} +${gained} pts`);
|
|
||||||
broadcast({ type: 'rootme_flag', login: entry.login, gained, newScore: entry.score });
|
|
||||||
}
|
|
||||||
rootmePlayerCache[id] = entry;
|
|
||||||
rootmePrevScores[entry.login] = entry.score;
|
|
||||||
if (rootmeCache) {
|
|
||||||
const idx = rootmeCache.findIndex(u => u.login === entry.login);
|
|
||||||
if (idx !== -1) rootmeCache[idx] = entry; else rootmeCache.push(entry);
|
|
||||||
rootmeCache.sort((a, b) => b.score - a.score);
|
|
||||||
broadcast({ type: 'rootme_update', ranking: rootmeCache });
|
|
||||||
}
|
|
||||||
console.log(`[rootme] retry OK for id "${id}" (${entry.login})`);
|
|
||||||
}
|
|
||||||
retryQueue.delete(id);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[rootme] retry error for id "${id}":`, err.message);
|
|
||||||
retryQueue.delete(id);
|
|
||||||
}
|
|
||||||
await sleep(ROOTME_REQUEST_DELAY_MS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollRootme() {
|
|
||||||
const apiKey = process.env.ROOTME_API_KEY;
|
const apiKey = process.env.ROOTME_API_KEY;
|
||||||
if (!apiKey) return;
|
if (!apiKey) return;
|
||||||
|
|
||||||
|
let ids;
|
||||||
try {
|
try {
|
||||||
const ranking = await fetchRootmeRanking(apiKey);
|
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;
|
||||||
|
|
||||||
// Detect score gains and broadcast flag events
|
const delayMs = Math.max(ROOTME_MIN_DELAY_MS, Math.floor(ROOTME_TARGET_INTERVAL_MS / ids.length));
|
||||||
const isFirstPoll = Object.keys(rootmePrevScores).length === 0;
|
const headers = { 'Cookie': `api_key=${apiKey}`, 'User-Agent': 'CyberDashboard/1.0' };
|
||||||
if (!isFirstPoll) {
|
|
||||||
ranking.forEach(user => {
|
rootmeCache = [];
|
||||||
const prev = rootmePrevScores[user.login];
|
let idx = 0;
|
||||||
if (prev !== undefined && user.score > prev) {
|
|
||||||
const gained = user.score - prev;
|
async function pollNext() {
|
||||||
console.log(`[rootme] FLAG ! ${user.login} +${gained} pts (${prev} -> ${user.score})`);
|
const id = ids[idx];
|
||||||
broadcast({ type: 'rootme_flag', login: user.login, gained, newScore: user.score });
|
idx = (idx + 1) % ids.length;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`https://api.www.root-me.org/auteurs/${id}`, { headers, timeout: 10000 });
|
||||||
|
if (resp.status === 429) {
|
||||||
|
console.warn(`[rootme] 429 pour id "${id}", prochain tour dans ${delayMs / 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;
|
||||||
|
console.log(`[rootme] FLAG ! ${entry.login} +${gained} pts (${prev} → ${entry.score})`);
|
||||||
|
broadcast({ type: 'rootme_flag', login: entry.login, gained, newScore: entry.score });
|
||||||
|
}
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
ranking.forEach(u => { rootmePrevScores[u.login] = u.score; });
|
setTimeout(pollNext, delayMs);
|
||||||
rootmeCache = ranking;
|
|
||||||
broadcast({ type: 'rootme_update', ranking });
|
|
||||||
console.log(`[rootme] polled — ${ranking.length} joueur(s)`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[rootme] poll error:', err.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) => {
|
app.get('/api/rootme', (req, res) => {
|
||||||
|
|
@ -360,7 +335,9 @@ app.get('/api/rootme', (req, res) => {
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`Cyber Dashboard running on http://localhost:${PORT}`);
|
console.log(`Cyber Dashboard running on http://localhost:${PORT}`);
|
||||||
pollRootme();
|
pollAnssi();
|
||||||
setInterval(pollRootme, ROOTME_POLL_MS);
|
setInterval(pollAnssi, FEED_POLL_MS);
|
||||||
setInterval(retryRateLimited, 30 * 1000);
|
pollGeo();
|
||||||
|
setInterval(pollGeo, FEED_POLL_MS);
|
||||||
|
startRootmePoller();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue