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:
6
.env.example
Normal file
6
.env.example
Normal 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
17
Dockerfile
Normal 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
23
docker-compose.yml
Normal 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
17
package.json
Normal 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
831
public/index.html
Normal 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">🇬🇧</button>
|
||||
<button class="lang-btn" data-lang="es" onclick="setLang('es')" title="Español">🇪🇸</button>
|
||||
<button class="lang-btn" data-lang="ro" onclick="setLang('ro')" title="Română">🇷🇴</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-num glow" id="s-total">…</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">…</div>
|
||||
<div class="stat-lbl" data-i18n="stat_today">TODAY</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-num" id="s-7d">…</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">…</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">…</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">—</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">▶ MOST COMMON ATTACK TYPE (30D):</span>
|
||||
<span class="tt-form" id="tt-form">—</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">▶ 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">▶ 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">▶ 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">▶ 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…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel atk-panel">
|
||||
<div class="panel-hdr" data-i18n="offenders_title">▶ 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…</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">🇪🇺 Made & hosted in the EU by</span>
|
||||
<a href="https://cloudhost.es" target="_blank" rel="noopener">Cloud Host</a>
|
||||
|
|
||||
<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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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
289
server.js
Normal 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 1–100 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'}`);
|
||||
});
|
||||
Reference in New Issue
Block a user