feat: initial WordPress Attack Intelligence API Docker stack

Port 3083, SQLite WAL, IP geo-enrichment, SSE live feed, rate limiter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 09:37:13 +02:00
commit a89df2e412
6 changed files with 1183 additions and 0 deletions

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
PORT=3083
DB_PATH=/data/attacks.db
NODE_ENV=production
# Set a strong random token — all WP sites must send this as: Authorization: Bearer <token>
# Leave empty to run in open mode (dev only)
API_TOKEN=change-me-to-a-long-random-string

17
Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM node:20-alpine
# Native deps for better-sqlite3
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
RUN mkdir -p /data
EXPOSE 3083
VOLUME ["/data"]
CMD ["node", "server.js"]

23
docker-compose.yml Normal file
View File

@@ -0,0 +1,23 @@
services:
attack-api:
build: .
container_name: attack-api
restart: unless-stopped
ports:
- "3083:3083"
volumes:
- attack-data:/data
environment:
- PORT=3083
- DB_PATH=/data/attacks.db
- NODE_ENV=production
- API_TOKEN=${API_TOKEN:-change-me-to-a-long-random-string}
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3083/api/v1/health"]
interval: 30s
timeout: 5s
retries: 3
volumes:
attack-data:
driver: local

17
package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "attack-api",
"version": "1.0.0",
"description": "WordPress WAF attack intelligence API + live dashboard",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"better-sqlite3": "^9.4.3",
"express": "^4.18.2"
},
"engines": {
"node": ">=18"
}
}

831
public/index.html Normal file
View File

@@ -0,0 +1,831 @@
<!DOCTYPE html>
<html lang="en" id="html-root">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ATK // WORDPRESS ATTACK INTELLIGENCE</title>
<style>
:root {
--bg: #0d0f0f;
--bg2: #151515;
--red: #ff4444;
--red2: #ff6b6b;
--border: #2a2020;
--dim: #6b5555;
--dim2: #8b6666;
--muted: #1e1212;
--amber: #ff8c42;
--green: #3fb950;
--white: #e8d8d8;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scrollbar-color: var(--dim2) var(--bg); scrollbar-width: thin; }
body {
background: var(--bg);
color: var(--red2);
font-family: 'Courier New', 'Lucida Console', monospace;
font-size: 13px;
line-height: 1.5;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
body::after {
content: '';
position: fixed;
inset: 0;
background: repeating-linear-gradient(
0deg, transparent, transparent 2px,
rgba(0,0,0,.05) 2px, rgba(0,0,0,.05) 4px
);
pointer-events: none;
z-index: 9999;
}
.glow { text-shadow: 0 0 12px var(--red), 0 0 24px var(--red); }
.glow-sm { text-shadow: 0 0 6px var(--red); }
header {
border-bottom: 1px solid var(--border);
padding: 8px 20px;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg2);
flex-shrink: 0;
flex-wrap: wrap;
gap: 6px;
}
.logo {
font-size: 16px;
font-weight: bold;
letter-spacing: 4px;
color: var(--red);
}
.logo em { color: var(--amber); font-style: normal; }
.header-right {
display: flex;
align-items: center;
gap: 14px;
font-size: 11px;
color: var(--dim2);
letter-spacing: 1px;
}
.live-dot {
display: inline-block;
width: 8px; height: 8px;
background: var(--red);
border-radius: 50%;
animation: blink 1s step-end infinite;
box-shadow: 0 0 6px var(--red);
vertical-align: middle;
margin-right: 4px;
}
@keyframes blink { 50% { opacity: 0; } }
.lang-switcher { display: flex; gap: 3px; align-items: center; }
.lang-btn {
background: none; border: none; cursor: pointer;
padding: 1px 2px; line-height: 1;
transition: opacity .15s, font-size .15s;
}
.lang-btn.active { font-size: 20px; opacity: 1; }
.lang-btn.inactive { font-size: 14px; opacity: 0.35; }
main {
flex: 1;
min-height: 0;
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 8px;
overflow: hidden;
}
.stats-row {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
flex-shrink: 0;
}
.stat-card {
background: var(--bg2);
border: 1px solid var(--border);
padding: 10px 10px 8px;
text-align: center;
position: relative;
transition: border-color .2s;
}
.stat-card::before {
content: ''; position: absolute;
top: 0; left: 0; right: 0; height: 2px;
background: var(--muted);
transition: background .2s, box-shadow .2s;
}
.stat-card:hover { border-color: var(--dim2); }
.stat-card:hover::before { background: var(--red); box-shadow: 0 0 8px var(--red); }
.stat-num {
font-size: 26px; font-weight: bold;
letter-spacing: 2px; line-height: 1.1;
color: var(--red);
}
.stat-lbl {
font-size: 9px; letter-spacing: 2px;
text-transform: uppercase;
color: var(--dim);
margin-top: 3px;
}
#top-type {
background: var(--bg2);
border: 1px solid #2a1a1a;
border-left: 3px solid var(--red);
padding: 7px 14px;
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
flex-shrink: 0;
}
#top-type .tt-label { font-size: 10px; letter-spacing: 2px; color: var(--dim2); }
#top-type .tt-form { font-size: 14px; font-weight: bold; color: var(--red); text-shadow: 0 0 10px var(--red); }
#top-type .tt-hits { font-size: 11px; color: var(--dim2); }
#top-type .tt-pct { font-size: 11px; color: var(--dim); }
.content-grid {
display: grid;
grid-template-columns: 1fr 430px;
gap: 8px;
flex: 1;
min-height: 0;
overflow: hidden;
}
.left-col {
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
min-height: 0;
}
.left-col::-webkit-scrollbar { width: 3px; }
.left-col::-webkit-scrollbar-track { background: var(--bg); }
.left-col::-webkit-scrollbar-thumb { background: var(--dim2); }
.right-col {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
overflow: hidden;
}
.panel { background: var(--bg2); border: 1px solid var(--border); }
.panel-hdr {
padding: 6px 12px;
border-bottom: 1px solid var(--border);
font-size: 10px; letter-spacing: 2px;
color: var(--amber);
display: flex; align-items: center; justify-content: space-between;
}
.panel-body { padding: 10px 12px; }
#chart { width: 100%; height: 72px; display: block; }
.bars-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.bar-section-title { font-size: 10px; letter-spacing: 2px; color: var(--amber); margin-bottom: 6px; }
.bar-list { list-style: none; }
.bar-item {
display: grid;
grid-template-columns: 130px 1fr 50px;
align-items: center;
gap: 6px; padding: 2px 0; font-size: 11px;
}
.bar-lbl { color: var(--white); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bar-track { background: var(--muted); height: 6px; }
.bar-fill { background: var(--red); height: 100%; transition: width .5s ease; }
.bar-fill-amber { background: var(--amber); }
.bar-cnt { color: var(--dim); font-size: 11px; text-align: right; }
/* feed panel */
.feed-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
#feed {
flex: 1;
overflow-y: auto;
padding: 6px 12px;
font-size: 11px;
line-height: 1.7;
min-height: 0;
}
#feed::-webkit-scrollbar { width: 3px; }
#feed::-webkit-scrollbar-track { background: var(--bg); }
#feed::-webkit-scrollbar-thumb { background: var(--dim2); }
.feed-row {
border-bottom: 1px solid var(--muted);
padding: 3px 0;
}
.feed-ts { color: var(--dim2); font-size: 10px; }
.feed-ip { color: var(--amber); font-weight: bold; }
.feed-geo { color: var(--dim); font-size: 10px; }
.feed-type { font-weight: bold; font-size: 11px; }
.feed-source { color: var(--dim2); font-size: 10px; }
.feed-desc { color: var(--dim); font-size: 10px; }
.feed-payload { color: var(--dim); font-size: 10px; font-style: italic;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 340px; }
/* attack type color classes */
.atk-sqli { color: #a855f7; }
.atk-xss { color: #f97316; }
.atk-lfi { color: #eab308; }
.atk-rfi { color: #06b6d4; }
.atk-cmdi { color: #ef4444; }
.atk-xxe { color: #8b5cf6; }
.atk-php_inject { color: #ec4899; }
.atk-ssrf { color: #14b8a6; }
.atk-wp_specific { color: #f59e0b; }
.atk-other { color: var(--dim2); }
.feed-footer {
padding: 5px 12px;
border-top: 1px solid var(--border);
font-size: 10px; color: var(--dim);
display: flex; justify-content: space-between;
flex-shrink: 0;
}
.cursor::after { content: '█'; animation: blink 1s step-end infinite; }
/* offenders table */
.atk-panel {
flex-shrink: 0;
max-height: 260px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.atk-scroll { overflow-y: auto; flex: 1; }
.atk-scroll::-webkit-scrollbar { width: 3px; }
.atk-scroll::-webkit-scrollbar-track { background: var(--bg); }
.atk-scroll::-webkit-scrollbar-thumb { background: var(--dim2); }
.atk-table { width: 100%; border-collapse: collapse; font-size: 11px; }
.atk-table th {
padding: 5px 10px;
text-align: left; color: var(--amber);
font-size: 9px; letter-spacing: 2px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
position: sticky; top: 0; background: var(--bg2);
}
.atk-table td { padding: 4px 10px; border-bottom: 1px solid var(--muted); }
.atk-table tr:hover td { background: var(--muted); }
.atk-rank { color: var(--dim2); font-size: 10px; }
.atk-ip { color: var(--amber); font-weight: bold; }
.atk-hits { color: var(--red); font-weight: bold; }
.atk-asn { color: var(--dim); font-size: 10px; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mini-bar { height: 6px; background: var(--muted); min-width: 40px; width: 100%; }
.mini-fill { background: var(--red); height: 100%; box-shadow: 0 0 4px var(--red); transition: width .5s; }
footer {
border-top: 1px solid var(--border);
padding: 6px 20px;
font-size: 10px; color: var(--dim2);
display: flex; justify-content: space-between; align-items: center;
letter-spacing: 1px; flex-shrink: 0; flex-wrap: wrap; gap: 4px;
}
footer a { color: var(--dim); text-decoration: none; }
footer a:hover { color: var(--red2); }
.footer-eu { display: flex; align-items: center; gap: 5px; }
@media (max-width: 1100px) {
body { overflow: auto; height: auto; }
.stats-row { grid-template-columns: repeat(3, 1fr); }
.content-grid { grid-template-columns: 1fr; height: auto; }
.left-col { overflow: visible; }
.right-col { height: 700px; }
.feed-panel { flex: 1; }
}
@media (max-width: 640px) {
.stats-row { grid-template-columns: 1fr 1fr; }
.bars-2col { grid-template-columns: 1fr; }
.bar-item { grid-template-columns: 100px 1fr 42px; }
}
</style>
</head>
<body>
<header>
<div class="logo glow">[ATK<em>]</em> // WORDPRESS ATTACK INTELLIGENCE</div>
<div class="header-right">
<span id="clock">--:--:--</span>
<span><span class="live-dot"></span><span data-i18n="live_feed">LIVE FEED</span></span>
<div class="lang-switcher">
<button class="lang-btn" data-lang="en" onclick="setLang('en')" title="English">&#x1F1EC;&#x1F1E7;</button>
<button class="lang-btn" data-lang="es" onclick="setLang('es')" title="Espa&#xF1;ol">&#x1F1EA;&#x1F1F8;</button>
<button class="lang-btn" data-lang="ro" onclick="setLang('ro')" title="Rom&#xE2;n&#x103;">&#x1F1F7;&#x1F1F4;</button>
</div>
</div>
</header>
<main>
<div class="stats-row">
<div class="stat-card">
<div class="stat-num glow" id="s-total">&#x2026;</div>
<div class="stat-lbl" data-i18n="stat_total">TOTAL ATTACKS</div>
</div>
<div class="stat-card">
<div class="stat-num glow" id="s-today">&#x2026;</div>
<div class="stat-lbl" data-i18n="stat_today">TODAY</div>
</div>
<div class="stat-card">
<div class="stat-num" id="s-7d">&#x2026;</div>
<div class="stat-lbl" data-i18n="stat_7d">LAST 7 DAYS</div>
</div>
<div class="stat-card">
<div class="stat-num" id="s-30d">&#x2026;</div>
<div class="stat-lbl" data-i18n="stat_30d">LAST 30 DAYS</div>
</div>
<div class="stat-card">
<div class="stat-num" id="s-sites">&#x2026;</div>
<div class="stat-lbl" data-i18n="stat_sites">SITES REPORTING</div>
</div>
<div class="stat-card">
<div class="stat-num" id="s-top-type" style="font-size:14px;letter-spacing:1px">&#x2014;</div>
<div class="stat-lbl" data-i18n="stat_top_type">TOP ATTACK TYPE</div>
</div>
</div>
<div id="top-type">
<span class="tt-label" data-i18n="top_type_label">&#x25B6; MOST COMMON ATTACK TYPE (30D):</span>
<span class="tt-form" id="tt-form">&#x2014;</span>
<span class="tt-hits" id="tt-hits"></span>
<span class="tt-pct" id="tt-pct"></span>
</div>
<div class="content-grid">
<div class="left-col">
<div class="panel">
<div class="panel-hdr">
<span data-i18n="chart_title">&#x25B6; 24H ACTIVITY TREND</span>
<span id="chart-peak" style="color:var(--dim);font-size:11px"></span>
</div>
<div class="panel-body" style="padding:8px 10px">
<canvas id="chart"></canvas>
</div>
</div>
<div class="panel">
<div class="panel-hdr" data-i18n="types_title">&#x25B6; ATTACK TYPES // LAST 30 DAYS</div>
<div class="panel-body">
<div class="bars-2col">
<div>
<div class="bar-section-title" data-i18n="attack_types">ATTACK TYPES</div>
<ul class="bar-list" id="bars-types"></ul>
</div>
<div>
<div class="bar-section-title" data-i18n="top_params">TOP PARAMETERS</div>
<ul class="bar-list" id="bars-params"></ul>
</div>
</div>
</div>
</div>
<div class="panel">
<div class="panel-hdr" data-i18n="sources_title">&#x25B6; SOURCES + URIS // LAST 30 DAYS</div>
<div class="panel-body">
<div class="bars-2col">
<div>
<div class="bar-section-title" data-i18n="sources">SOURCES</div>
<ul class="bar-list" id="bars-sources"></ul>
</div>
<div>
<div class="bar-section-title" data-i18n="top_uris">TOP URIS</div>
<ul class="bar-list" id="bars-uris"></ul>
</div>
</div>
</div>
</div>
</div>
<div class="right-col">
<div class="panel feed-panel">
<div class="panel-hdr">
<span data-i18n="feed_title">&#x25B6; LIVE ATTACK FEED</span>
<span id="feed-count" style="color:var(--dim);font-size:11px">0 <span data-i18n="events">events</span></span>
</div>
<div id="feed"></div>
<div class="feed-footer">
<span class="cursor"></span>
<span id="feed-status" data-i18n="connecting">connecting&#x2026;</span>
</div>
</div>
<div class="panel atk-panel">
<div class="panel-hdr" data-i18n="offenders_title">&#x25B6; TOP OFFENDERS // LAST 30 DAYS</div>
<div class="atk-scroll">
<table class="atk-table">
<thead>
<tr>
<th>#</th>
<th data-i18n="col_ip">IP ADDRESS</th>
<th data-i18n="col_country">COUNTRY</th>
<th data-i18n="col_hits">HITS</th>
<th data-i18n="col_asn">AS</th>
</tr>
</thead>
<tbody id="atk-body">
<tr><td colspan="5" style="text-align:center;padding:14px;color:var(--dim)" data-i18n="loading">Loading&#x2026;</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
<footer>
<span data-i18n="footer_copy">WORDPRESS ATTACK INTELLIGENCE // CENTRALIZED WAF TELEMETRY</span>
<div class="footer-eu">
<span data-i18n="made_in_eu">&#x1F1EA;&#x1F1FA; Made &amp; hosted in the EU by</span>
<a href="https://cloudhost.es" target="_blank" rel="noopener">Cloud Host</a>
&nbsp;|&nbsp;
<span data-i18n="refreshed">REFRESHED:</span> <span id="last-update">--</span>
</div>
</footer>
<script>
// ── i18n ──────────────────────────────────────────────────────────────────────
const I18N = {
en: {
live_feed: 'LIVE FEED',
stat_total: 'TOTAL ATTACKS',
stat_today: 'TODAY',
stat_7d: 'LAST 7 DAYS',
stat_30d: 'LAST 30 DAYS',
stat_sites: 'SITES REPORTING',
stat_top_type: 'TOP ATTACK TYPE',
top_type_label: '\u25B6 MOST COMMON ATTACK TYPE (30D):',
chart_title: '\u25B6 24H ACTIVITY TREND',
types_title: '\u25B6 ATTACK TYPES // LAST 30 DAYS',
attack_types: 'ATTACK TYPES',
top_params: 'TOP PARAMETERS',
sources_title: '\u25B6 SOURCES + URIS // LAST 30 DAYS',
sources: 'SOURCES',
top_uris: 'TOP URIS',
feed_title: '\u25B6 LIVE ATTACK FEED',
events: 'events',
connecting: 'connecting\u2026',
connected: 'connected',
reconnecting: 'reconnecting\u2026',
offenders_title: '\u25B6 TOP OFFENDERS // LAST 30 DAYS',
col_ip: 'IP ADDRESS',
col_country: 'COUNTRY',
col_hits: 'HITS',
col_asn: 'AS',
loading: 'Loading\u2026',
no_data: 'No data yet',
footer_copy: 'WORDPRESS ATTACK INTELLIGENCE // CENTRALIZED WAF TELEMETRY',
refreshed: 'REFRESHED:',
made_in_eu: '\u1F1EA\u1F1FA Made & hosted in the EU by',
},
es: {
live_feed: 'EN VIVO',
stat_total: 'TOTAL ATAQUES',
stat_today: 'HOY',
stat_7d: '\xDALTIMOS 7 D\xCDAS',
stat_30d: '\xDALTIMOS 30 D\xCDAS',
stat_sites: 'SITIOS REPORTANDO',
stat_top_type: 'TIPO PRINCIPAL',
top_type_label: '\u25B6 TIPO DE ATAQUE M\xC1S COM\xDAN (30D):',
chart_title: '\u25B6 TENDENCIA 24H',
types_title: '\u25B6 TIPOS DE ATAQUE // \xDALTIMOS 30 D\xCDAS',
attack_types: 'TIPOS DE ATAQUE',
top_params: 'TOP PAR\xC1METROS',
sources_title: '\u25B6 FUENTES + URIS // \xDALTIMOS 30 D\xCDAS',
sources: 'FUENTES',
top_uris: 'TOP URIS',
feed_title: '\u25B6 FEED EN VIVO',
events: 'eventos',
connecting: 'conectando\u2026',
connected: 'conectado',
reconnecting: 'reconectando\u2026',
offenders_title: '\u25B6 TOP OFENSORES // \xDALTIMOS 30 D\xCDAS',
col_ip: 'DIRECCI\xD3N IP',
col_country: 'PA\xCDS',
col_hits: 'IMPACTOS',
col_asn: 'AS',
loading: 'Cargando\u2026',
no_data: 'Sin datos a\xFAn',
footer_copy: 'INTELIGENCIA DE ATAQUES WP // TELEMETR\xCDA WAF CENTRALIZADA',
refreshed: 'ACTUALIZADO:',
made_in_eu: '\u1F1EA\u1F1FA Hecho y alojado en la UE por',
},
ro: {
live_feed: 'LIVE',
stat_total: 'TOTAL ATACURI',
stat_today: 'AZI',
stat_7d: 'ULTIMELE 7 ZILE',
stat_30d: 'ULTIMELE 30 ZILE',
stat_sites: 'SITE-URI RAPORT\xC2ND',
stat_top_type: 'TIP PRINCIPAL',
top_type_label: '\u25B6 CEL MAI FRECVENT TIP DE ATAC (30Z):',
chart_title: '\u25B6 TENDIN\u021A\u0102 24H',
types_title: '\u25B6 TIPURI ATAC // ULTIMELE 30 ZILE',
attack_types: 'TIPURI ATAC',
top_params: 'TOP PARAMETRI',
sources_title: '\u25B6 SURSE + URIS // ULTIMELE 30 ZILE',
sources: 'SURSE',
top_uris: 'TOP URIS',
feed_title: '\u25B6 FLUX LIVE ATACURI',
events: 'evenimente',
connecting: 'conectare\u2026',
connected: 'conectat',
reconnecting: 'reconectare\u2026',
offenders_title: '\u25B6 TOP ATACATORI // ULTIMELE 30 ZILE',
col_ip: 'ADRES\u0102 IP',
col_country: '\u021AAR\u0102',
col_hits: 'ACCESE',
col_asn: 'AS',
loading: 'Se \xEEncarc\u0103\u2026',
no_data: 'F\u0103r\u0103 date \xEEnc\u0103',
footer_copy: 'INTELIGEN\u021A\u0102 ATACURI WP // TELEMETRIE WAF CENTRALIZAT\u0102',
refreshed: 'ACTUALIZAT:',
made_in_eu: '\u1F1F7\u1F1F4 Realizat \u0219i g\u0103zduit \xEEn UE de',
},
};
function detectLang() {
const s = localStorage.getItem('atk_lang');
if (s && I18N[s]) return s;
const nav = (navigator.language || 'en').slice(0, 2).toLowerCase();
return I18N[nav] ? nav : 'en';
}
let currentLang = detectLang();
function t(k) { return (I18N[currentLang] || I18N.en)[k] || (I18N.en[k] || k); }
function setLang(lang) {
if (!I18N[lang]) return;
currentLang = lang;
localStorage.setItem('atk_lang', lang);
document.getElementById('html-root').lang = lang;
applyTranslations();
updateLangButtons();
}
function applyTranslations() {
document.querySelectorAll('[data-i18n]').forEach(el => {
if (!el.children.length) el.textContent = t(el.getAttribute('data-i18n'));
});
}
function updateLangButtons() {
document.querySelectorAll('.lang-btn').forEach(btn => {
btn.className = 'lang-btn ' + (btn.dataset.lang === currentLang ? 'active' : 'inactive');
});
}
// ── Utilities ─────────────────────────────────────────────────────────────────
function flag(cc) {
if (!cc || cc.length !== 2) return '';
return String.fromCodePoint(...[...cc.toUpperCase()].map(c => c.charCodeAt(0) + 127397));
}
function esc(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function fmtTime(ts) { return new Date(ts * 1000).toISOString().slice(11, 19); }
const clockEl = document.getElementById('clock');
function tick() { clockEl.textContent = new Date().toISOString().slice(11, 19) + ' UTC'; }
tick(); setInterval(tick, 1000);
// ── CountUp ───────────────────────────────────────────────────────────────────
const ctMap = new Map();
function countUp(el, to) {
const from = parseInt(el.textContent.replace(/,/g, '')) || 0;
if (from === to) return;
const steps = 25, diff = to - from; let s = 0;
clearInterval(ctMap.get(el));
const id = setInterval(() => {
s++;
el.textContent = Math.round(from + diff * (s / steps)).toLocaleString();
if (s >= steps) { el.textContent = to.toLocaleString(); clearInterval(id); }
}, 16);
ctMap.set(el, id);
}
// ── Attack type color class ───────────────────────────────────────────────────
function atkClass(type) {
const safe = (type || 'other').toLowerCase().replace(/[^a-z_]/g, '');
return 'atk-' + safe;
}
// ── Bar charts ────────────────────────────────────────────────────────────────
function renderBars(listEl, items, labelKey, fillClass) {
if (!items || !items.length) {
listEl.innerHTML = '<li style="color:var(--dim);font-size:11px;padding:3px 0">' + t('no_data') + '</li>';
return;
}
const max = items[0].hits;
listEl.innerHTML = items.map(item => {
const label = item[labelKey] || item.attack_type || item.source || item.param || item.uri || item.ip || '?';
const pct = max > 0 ? Math.round(item.hits / max * 100) : 0;
return '<li class="bar-item">' +
'<span class="bar-lbl" title="' + esc(label) + '">' + esc(label) + '</span>' +
'<div class="bar-track"><div class="bar-fill ' + (fillClass || '') + '" style="width:' + pct + '%"></div></div>' +
'<span class="bar-cnt">' + item.hits.toLocaleString() + '</span>' +
'</li>';
}).join('');
}
// ── 24h chart ─────────────────────────────────────────────────────────────────
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
function drawChart(hourly) {
const W = canvas.offsetWidth || 600, H = 72;
canvas.width = W * (window.devicePixelRatio || 1);
canvas.height = H * (window.devicePixelRatio || 1);
ctx.scale(window.devicePixelRatio || 1, window.devicePixelRatio || 1);
ctx.clearRect(0, 0, W, H);
if (!hourly || !hourly.length) {
ctx.fillStyle = '#6b3333'; ctx.font = '12px Courier New';
ctx.fillText(t('no_data'), 10, 38); return;
}
const base = Math.floor(Date.now() / 1000 / 3600) * 3600;
const map = new Map(hourly.map(r => [r.h, r.n]));
const hrs = Array.from({ length: 24 }, (_, i) => ({ h: base - (23 - i) * 3600, n: map.get(base - (23 - i) * 3600) || 0 }));
const max = Math.max(...hrs.map(h => h.n), 1);
const pad = { l: 2, r: 2, t: 5, b: 3 };
const cW = W - pad.l - pad.r, cH = H - pad.t - pad.b;
const bW = cW / hrs.length - 1;
ctx.strokeStyle = '#2a1212'; ctx.lineWidth = 1;
[0.33, 0.66].forEach(f => {
const y = pad.t + cH * (1 - f);
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(W - pad.r, y); ctx.stroke();
});
hrs.forEach((h, i) => {
const x = pad.l + i * (cW / hrs.length);
const bH = Math.max(1, h.n / max * cH), y = pad.t + cH - bH;
const g = ctx.createLinearGradient(0, y, 0, y + bH);
g.addColorStop(0, '#ff4444'); g.addColorStop(1, '#3a0a0a');
ctx.fillStyle = g; ctx.fillRect(x + 0.5, y, Math.max(bW, 1), bH);
if (h.n === max) {
ctx.shadowColor = '#ff4444'; ctx.shadowBlur = 10;
ctx.fillRect(x + 0.5, y, Math.max(bW, 1), bH);
ctx.shadowBlur = 0;
}
});
document.getElementById('chart-peak').textContent =
'PEAK ' + max.toLocaleString() + '/hr TOTAL ' + hrs.reduce((a, h) => a + h.n, 0).toLocaleString();
}
window.addEventListener('resize', () => { if (window._hourly) drawChart(window._hourly); });
// ── Top offenders table ───────────────────────────────────────────────────────
function renderOffenders(ips) {
const tbody = document.getElementById('atk-body');
if (!ips || !ips.length) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:14px;color:var(--dim)">' + t('no_data') + '</td></tr>';
return;
}
tbody.innerHTML = ips.map((row, i) => {
const f = flag(row.country);
const asnNo = (row.asn || '').split(' ')[0];
return '<tr>' +
'<td class="atk-rank">#' + (i + 1) + '</td>' +
'<td class="atk-ip">' + esc(row.ip) + '</td>' +
'<td class="atk-geo">' + (f ? f + ' ' : '') + esc(row.country || '') + '</td>' +
'<td class="atk-hits">' + row.hits.toLocaleString() + '</td>' +
'<td class="atk-asn" title="' + esc(row.asn || '') + '">' + esc(asnNo) + '</td>' +
'</tr>';
}).join('');
}
// ── Live feed ─────────────────────────────────────────────────────────────────
let feedCount = 0;
const feedEl = document.getElementById('feed');
const feedCount$ = document.getElementById('feed-count');
const feedStatus = document.getElementById('feed-status');
function addRow(row) {
feedCount++;
feedCount$.textContent = feedCount.toLocaleString() + ' ' + t('events');
const el = document.createElement('div');
el.className = 'feed-row';
const f = flag(row.country || '');
const typeStr = (row.attack_type || 'other').toUpperCase();
const payload = (row.payload || '').slice(0, 60);
el.innerHTML =
'<span class="feed-ts">' + fmtTime(row.received_at) + '</span> ' +
'<span class="feed-ip">' + esc(row.ip || '?') + '</span>' +
(f ? ' <span class="feed-geo">' + f + ' ' + esc(row.country || '') + '</span>' : '') +
' <span class="feed-type ' + atkClass(row.attack_type) + '">[' + esc(typeStr) + ']</span>' +
(row.source ? ' <span class="feed-source">[' + esc(row.source) + ']</span>' : '') +
'<br>' +
'<span class="feed-desc">' + esc(row.rule_desc || '') + '</span>' +
(payload ? ' <span class="feed-payload">' + esc(payload) + (row.payload && row.payload.length > 60 ? '\u2026' : '') + '</span>' : '');
feedEl.prepend(el);
while (feedEl.children.length > 120) feedEl.removeChild(feedEl.lastChild);
}
async function seedFeed() {
try {
const r = await fetch('/api/v1/stats');
const s = await r.json();
if (s.recent) [...s.recent].reverse().forEach(addRow);
} catch {}
}
function connectSSE() {
const es = new EventSource('/api/v1/stream');
es.onopen = () => { feedStatus.textContent = t('connected'); };
es.onmessage = e => { try { JSON.parse(e.data).reverse().forEach(addRow); } catch {} };
es.onerror = () => {
es.close();
feedStatus.textContent = t('reconnecting');
setTimeout(connectSSE, 5000);
};
}
// ── Stats fetch + render ──────────────────────────────────────────────────────
async function fetchStats() {
try {
const r = await fetch('/api/v1/stats');
if (!r.ok) return;
const s = await r.json();
countUp(document.getElementById('s-total'), s.total);
countUp(document.getElementById('s-today'), s.today);
countUp(document.getElementById('s-7d'), s.last_7d);
countUp(document.getElementById('s-30d'), s.last_30d);
countUp(document.getElementById('s-sites'), s.total_sites);
renderBars(document.getElementById('bars-types'), s.top_attack_types, 'attack_type');
renderBars(document.getElementById('bars-params'), s.top_params, 'param', 'bar-fill-amber');
renderBars(document.getElementById('bars-sources'), s.top_sources, 'source');
renderBars(document.getElementById('bars-uris'), s.top_uris, 'uri', 'bar-fill-amber');
renderOffenders(s.top_ips);
if (s.top_attack_types && s.top_attack_types.length) {
const top = s.top_attack_types[0];
const topEl = document.getElementById('s-top-type');
topEl.textContent = top.attack_type.toUpperCase();
topEl.className = 'stat-num ' + atkClass(top.attack_type);
document.getElementById('tt-form').textContent = top.attack_type.toUpperCase();
document.getElementById('tt-form').className = 'tt-form ' + atkClass(top.attack_type);
document.getElementById('tt-hits').textContent = top.hits.toLocaleString() + ' hits';
const pct = s.last_30d > 0 ? Math.round(top.hits / s.last_30d * 100) : 0;
document.getElementById('tt-pct').textContent = '(' + pct + '% of all attacks)';
}
window._hourly = s.hourly;
drawChart(s.hourly);
document.getElementById('last-update').textContent = new Date().toISOString().slice(11, 19) + ' UTC';
} catch {}
}
// ── Boot ──────────────────────────────────────────────────────────────────────
feedStatus.textContent = t('connecting');
applyTranslations();
updateLangButtons();
seedFeed();
connectSSE();
fetchStats();
setInterval(fetchStats, 6000);
</script>
</body>
</html>

289
server.js Normal file
View File

@@ -0,0 +1,289 @@
'use strict';
const express = require('express');
const Database = require('better-sqlite3');
const path = require('path');
const http = require('http');
const { timingSafeEqual } = require('crypto');
const app = express();
const PORT = Number(process.env.PORT) || 3083;
const DB = new Database(process.env.DB_PATH || '/data/attacks.db');
// ── Database ──────────────────────────────────────────────────────────────────
DB.pragma('journal_mode = WAL');
DB.pragma('synchronous = NORMAL');
DB.pragma('cache_size = -8000');
DB.exec(`
CREATE TABLE IF NOT EXISTS attacks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
received_at INTEGER NOT NULL DEFAULT (unixepoch()),
site_id TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
attack_type TEXT NOT NULL DEFAULT '',
rule_desc TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
param TEXT NOT NULL DEFAULT '',
payload TEXT NOT NULL DEFAULT '',
uri TEXT NOT NULL DEFAULT '',
method TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
asn TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS sites (
site_id TEXT PRIMARY KEY,
first_seen INTEGER NOT NULL DEFAULT (unixepoch()),
last_seen INTEGER NOT NULL DEFAULT (unixepoch()),
event_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_recv ON attacks(received_at DESC);
CREATE INDEX IF NOT EXISTS idx_ip ON attacks(ip);
CREATE INDEX IF NOT EXISTS idx_site ON attacks(site_id);
CREATE INDEX IF NOT EXISTS idx_attack_type ON attacks(attack_type);
CREATE INDEX IF NOT EXISTS idx_source ON attacks(source);
`);
// Migrations silently ignored if columns already exist
['country', 'asn', 'param', 'payload', 'uri', 'method', 'user_agent', 'rule_desc', 'source'].forEach(col => {
try { DB.exec(`ALTER TABLE attacks ADD COLUMN ${col} TEXT NOT NULL DEFAULT ''`); } catch {}
});
// ── Auth ──────────────────────────────────────────────────────────────────────
const API_TOKEN = (process.env.API_TOKEN || '').trim();
function requireToken(req, res, next) {
if (!API_TOKEN) return next();
const token = (req.headers['authorization'] || '').replace(/^Bearer\s+/, '');
const a = Buffer.alloc(128); Buffer.from(token, 'utf8').copy(a, 0, 0, 128);
const b = Buffer.alloc(128); Buffer.from(API_TOKEN, 'utf8').copy(b, 0, 0, 128);
if (!timingSafeEqual(a, b) || token !== API_TOKEN) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
}
// ── IP geo-enrichment ─────────────────────────────────────────────────────────
const stmtEnrich = DB.prepare('UPDATE attacks SET country=?, asn=? WHERE id=?');
const enrichCache = new Map();
function isPrivateIP(ip) {
return /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|127\.|::1$|fc|fd)/.test(ip);
}
function enrichIP(rowId, ip) {
if (!ip || ip === '?' || isPrivateIP(ip)) return;
const now = Date.now();
if ((enrichCache.get(ip) || 0) > now) return;
enrichCache.set(ip, now + 3_600_000);
http.get(
`http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,countryCode,as`,
{ timeout: 5000 },
res => {
let data = '';
res.on('data', d => data += d);
res.on('end', () => {
try {
const j = JSON.parse(data);
if (j.status === 'success') {
stmtEnrich.run(
(j.countryCode || '').slice(0, 2),
(j.as || '').slice(0, 50),
rowId
);
}
} catch {}
});
}
).on('error', () => enrichCache.delete(ip));
}
// Background enrichment of unenriched rows
const stmtUnenriched = DB.prepare(
"SELECT id, ip FROM attacks WHERE country='' AND ip != '' AND ip != '?' LIMIT 5"
);
setInterval(() => {
for (const row of stmtUnenriched.all()) enrichIP(row.id, row.ip);
}, 20_000);
// ── Rate limiter ──────────────────────────────────────────────────────────────
const rl = new Map();
setInterval(() => { const n = Date.now(); for (const [k, v] of rl) if (n > v.r) rl.delete(k); }, 30_000);
function allowed(ip, max = 30, win = 60_000) {
const n = Date.now();
let e = rl.get(ip);
if (!e || n > e.r) { e = { c: 0, r: n + win }; rl.set(ip, e); }
return ++e.c <= max;
}
// ── Stats cache (30s TTL) ─────────────────────────────────────────────────────
let _cache = null, _cacheTs = 0;
function getStats() {
if (_cache && Date.now() - _cacheTs < 30_000) return _cache;
const now = Math.floor(Date.now() / 1000);
_cache = {
total: DB.prepare('SELECT COUNT(*) n FROM attacks').get().n,
today: DB.prepare('SELECT COUNT(*) n FROM attacks WHERE received_at > ?').get(now - 86400).n,
last_7d: DB.prepare('SELECT COUNT(*) n FROM attacks WHERE received_at > ?').get(now - 604800).n,
last_30d: DB.prepare('SELECT COUNT(*) n FROM attacks WHERE received_at > ?').get(now - 2592000).n,
total_sites: DB.prepare('SELECT COUNT(*) n FROM sites').get().n,
top_attack_types: DB.prepare(`
SELECT attack_type, COUNT(*) hits
FROM attacks WHERE received_at > ?
GROUP BY attack_type ORDER BY hits DESC LIMIT 10
`).all(now - 2592000),
top_ips: DB.prepare(`
SELECT ip, country, asn, COUNT(*) hits
FROM attacks WHERE received_at > ?
GROUP BY ip ORDER BY hits DESC LIMIT 10
`).all(now - 2592000),
top_params: DB.prepare(`
SELECT param, COUNT(*) hits
FROM attacks WHERE received_at > ? AND param != ''
GROUP BY param ORDER BY hits DESC LIMIT 10
`).all(now - 2592000),
top_uris: DB.prepare(`
SELECT uri, COUNT(*) hits
FROM attacks WHERE received_at > ? AND uri != ''
GROUP BY uri ORDER BY hits DESC LIMIT 10
`).all(now - 2592000),
top_sources: DB.prepare(`
SELECT source, COUNT(*) hits
FROM attacks WHERE received_at > ? AND source != ''
GROUP BY source ORDER BY hits DESC LIMIT 8
`).all(now - 2592000),
recent: DB.prepare(`
SELECT received_at, ip, country, attack_type, rule_desc, source, param, payload, method, site_id
FROM attacks ORDER BY id DESC LIMIT 40
`).all(),
hourly: DB.prepare(`
SELECT (received_at / 3600) * 3600 h, COUNT(*) n
FROM attacks WHERE received_at > ?
GROUP BY h ORDER BY h ASC
`).all(now - 86400),
};
_cacheTs = Date.now();
return _cache;
}
// ── SSE live stream ───────────────────────────────────────────────────────────
const sseClients = new Set();
let lastId = DB.prepare('SELECT MAX(id) id FROM attacks').get().id || 0;
setInterval(() => {
if (!sseClients.size) return;
const rows = DB.prepare(
'SELECT id, received_at, ip, country, attack_type, rule_desc, source, param, payload, method, site_id FROM attacks WHERE id > ? ORDER BY id ASC LIMIT 20'
).all(lastId);
if (!rows.length) return;
lastId = rows.at(-1).id;
const msg = `data: ${JSON.stringify(rows)}\n\n`;
for (const r of sseClients) { try { r.write(msg); } catch { sseClients.delete(r); } }
}, 2000);
// ── Prepared statements ───────────────────────────────────────────────────────
const stmtIns = DB.prepare(`
INSERT INTO attacks (received_at, site_id, ip, attack_type, rule_desc, source, param, payload, uri, method, user_agent, country, asn)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
`);
const stmtSite = DB.prepare(`
INSERT INTO sites (site_id, first_seen, last_seen, event_count) VALUES (?,?,?,?)
ON CONFLICT(site_id) DO UPDATE SET
last_seen = excluded.last_seen,
event_count = event_count + excluded.event_count
`);
const VALID_ATTACK_TYPES = new Set([
'sqli', 'xss', 'lfi', 'rfi', 'cmdi', 'xxe', 'php_inject', 'ssrf', 'wp_specific', 'other'
]);
const VALID_SOURCES = new Set(['GET', 'POST', 'COOKIE', 'URI', 'UA', 'HEADER', '']);
const insertBatch = DB.transaction((siteId, attacks) => {
const now = Math.floor(Date.now() / 1000);
const ids = [];
for (const a of attacks) {
const ts = a.logged_at ? Math.floor(new Date(a.logged_at) / 1000) : now;
const ip = String(a.ip || '').trim().slice(0, 45) || '?';
const r = stmtIns.run(
ts, siteId, ip,
String(a.attack_type || 'other').slice(0, 50),
String(a.rule_desc || '').slice(0, 255),
String(a.source || '').slice(0, 20),
String(a.param || '').slice(0, 200),
String(a.payload || '').slice(0, 500),
String(a.uri || '').slice(0, 500),
String(a.method || '').slice(0, 10),
String(a.user_agent || '').slice(0, 300),
'', '' // country/asn filled async
);
ids.push({ id: Number(r.lastInsertRowid), ip });
}
stmtSite.run(siteId, now, now, attacks.length);
return ids;
});
// ── Routes ────────────────────────────────────────────────────────────────────
app.use(express.json({ limit: '256kb' }));
app.use(express.static(path.join(__dirname, 'public')));
app.post('/api/v1/submit', requireToken, (req, res) => {
const clientIP = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|| req.socket.remoteAddress || '';
if (!allowed(clientIP)) return res.status(429).json({ error: 'Rate limit exceeded' });
const { site_hash, attacks } = req.body || {};
if (!site_hash || typeof site_hash !== 'string' || site_hash.length < 8) {
return res.status(400).json({ error: 'Invalid site_hash' });
}
if (!Array.isArray(attacks) || !attacks.length || attacks.length > 100) {
return res.status(400).json({ error: 'attacks must be array of 1100 items' });
}
try {
const ids = insertBatch(site_hash.slice(0, 64), attacks);
_cache = null;
setImmediate(() => ids.forEach(({ id, ip }) => enrichIP(id, ip)));
res.json({ ok: true, received: attacks.length });
} catch (e) {
console.error('[submit]', e.message);
res.status(500).json({ error: 'Internal error' });
}
});
app.get('/api/v1/stats', (_, res) => res.json(getStats()));
app.get('/api/v1/stream', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write(':\n\n');
sseClients.add(res);
req.on('close', () => sseClients.delete(res));
});
app.get('/api/v1/health', (_, res) =>
res.json({ ok: true, uptime: process.uptime(), sse_clients: sseClients.size })
);
app.listen(PORT, '0.0.0.0', () => {
console.log(`[attack-api] listening on :${PORT}`);
console.log(`[attack-api] db: ${process.env.DB_PATH || '/data/attacks.db'}`);
});