feat: centralized API dashboard + Docker container

API server (api/):
- Node.js + Express + SQLite (better-sqlite3, WAL mode)
- POST /api/v1/submit — receive blocks from WP sites (rate limited 30/min/IP)
- GET  /api/v1/stats  — public aggregated stats with 30s cache
- GET  /api/v1/stream — SSE live feed, pushed every 2s
- GET  /api/v1/health — health check
- IP masking: only first 2 octets stored (192.168.x.x)
- UA family detection: curl, Python, Go, bots, Chrome, etc.
- docker-compose.yml with named volume for SQLite persistence

Dashboard (api/public/index.html):
- Hacker/terminal aesthetic: black + matrix green, CRT scanlines
- Live stat cards: total blocked, today, 7d, 30d, sites reporting
- Canvas 24h activity trend chart with gradient bars
- CSS bar charts: form types, bot toolkit, block reasons
- Live SSE threat feed with countUp animation and auto-scroll
- Top 10 attackers table with frequency bars
- Polls /api/v1/stats every 6s, SSE for instant feed updates

WordPress plugin (honeypot-fields.php):
- SmartHoneypotAPIClient: queue (WP option) + WP-cron batch flush every 5min
- log_spam() now enqueues to central API after local DB write
- Admin 'Central API' tab: enable toggle, endpoint URL, sync stats, manual flush
- Cron properly registered/deregistered on activate/deactivate
This commit is contained in:
2026-03-09 19:21:41 +01:00
parent a3e38faffa
commit 6740180981
7 changed files with 1328 additions and 306 deletions

3
api/.env.example Normal file
View File

@@ -0,0 +1,3 @@
PORT=3000
DB_PATH=/data/honeypot.db
NODE_ENV=production

17
api/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 ci --omit=dev
COPY . .
RUN mkdir -p /data
EXPOSE 3000
VOLUME ["/data"]
CMD ["node", "server.js"]

22
api/docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
services:
honeypot-api:
build: .
container_name: honeypot-api
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- honeypot-data:/data
environment:
- PORT=3000
- DB_PATH=/data/honeypot.db
- NODE_ENV=production
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/v1/health"]
interval: 30s
timeout: 5s
retries: 3
volumes:
honeypot-data:
driver: local

17
api/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "honeypot-api",
"version": "1.0.0",
"description": "Centralized honeypot block aggregation API + 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"
}
}

621
api/public/index.html Normal file
View File

@@ -0,0 +1,621 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HONEYPOT // NETWORK MONITOR</title>
<style>
:root {
--bg: #000a00;
--bg2: #010f01;
--green: #00ff41;
--green2: #00cc33;
--dim: #006600;
--muted: #002800;
--border: #003300;
--red: #ff3333;
--amber: #ffaa00;
--white: #ccffcc;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scrollbar-color: var(--dim) var(--bg); scrollbar-width: thin; }
body {
background: var(--bg);
color: var(--green);
font-family: 'Courier New', 'Lucida Console', monospace;
font-size: 13px;
line-height: 1.5;
min-height: 100vh;
}
/* CRT scanline overlay */
body::after {
content: '';
position: fixed;
inset: 0;
background: repeating-linear-gradient(
0deg, transparent, transparent 2px,
rgba(0,0,0,.07) 2px, rgba(0,0,0,.07) 4px
);
pointer-events: none;
z-index: 9999;
}
.glow { text-shadow: 0 0 12px var(--green), 0 0 24px var(--green); }
.glow-sm { text-shadow: 0 0 6px var(--green); }
/* ── Header ─────────────────────────────────────────────────────────── */
header {
border-bottom: 1px solid var(--border);
padding: 10px 20px;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg2);
flex-wrap: wrap;
gap: 8px;
}
.logo {
font-size: 17px;
font-weight: bold;
letter-spacing: 4px;
color: var(--green);
}
.logo em { color: var(--amber); font-style: normal; }
.header-right {
display: flex;
align-items: center;
gap: 20px;
font-size: 11px;
color: var(--green2);
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: 5px;
}
@keyframes blink { 50% { opacity: 0; } }
/* ── Main ───────────────────────────────────────────────────────────── */
main { padding: 14px 16px; max-width: 1700px; margin: 0 auto; }
/* ── Stat cards ─────────────────────────────────────────────────────── */
.stats-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 10px;
margin-bottom: 14px;
}
.stat-card {
background: var(--bg2);
border: 1px solid var(--border);
padding: 14px 12px 12px;
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(--dim); }
.stat-card:hover::before { background: var(--green); box-shadow: 0 0 8px var(--green); }
.stat-num {
font-size: 30px;
font-weight: bold;
letter-spacing: 2px;
line-height: 1.1;
color: var(--green);
}
.stat-lbl {
font-size: 9px;
letter-spacing: 3px;
text-transform: uppercase;
color: var(--green2);
margin-top: 5px;
}
/* ── Content grid ───────────────────────────────────────────────────── */
.content-grid {
display: grid;
grid-template-columns: 1fr 360px;
gap: 10px;
margin-bottom: 10px;
align-items: start;
}
.left-col { display: flex; flex-direction: column; gap: 10px; }
/* ── Panel ──────────────────────────────────────────────────────────── */
.panel {
background: var(--bg2);
border: 1px solid var(--border);
}
.panel-hdr {
padding: 7px 14px;
border-bottom: 1px solid var(--border);
font-size: 10px;
letter-spacing: 3px;
color: var(--amber);
display: flex;
align-items: center;
justify-content: space-between;
}
.panel-body { padding: 12px 14px; }
/* ── 24h Chart ──────────────────────────────────────────────────────── */
#chart { width: 100%; height: 80px; display: block; }
/* ── Bar lists ──────────────────────────────────────────────────────── */
.bars-2col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.bar-section-title {
font-size: 10px;
letter-spacing: 2px;
color: var(--amber);
margin-bottom: 8px;
}
.bar-list { list-style: none; }
.bar-item {
display: grid;
grid-template-columns: 160px 1fr 55px;
align-items: center;
gap: 8px;
padding: 3px 0;
font-size: 12px;
}
.bar-lbl {
color: var(--white);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bar-track { background: var(--muted); height: 8px; }
.bar-fill { background: var(--green); height: 100%; transition: width .5s ease; }
.bar-cnt { color: var(--green2); font-size: 11px; text-align: right; }
/* ── Live feed ──────────────────────────────────────────────────────── */
.feed-panel { display: flex; flex-direction: column; min-height: 500px; }
#feed {
flex: 1;
overflow-y: auto;
padding: 8px 14px;
font-size: 11px;
line-height: 1.7;
}
#feed::-webkit-scrollbar { width: 3px; }
#feed::-webkit-scrollbar-track { background: var(--bg); }
#feed::-webkit-scrollbar-thumb { background: var(--dim); }
.feed-row {
display: grid;
grid-template-columns: 62px 90px auto;
gap: 6px;
border-bottom: 1px solid var(--muted);
padding: 1px 0;
align-items: start;
}
.feed-ts { color: var(--dim); }
.feed-ip { color: var(--amber); }
.feed-form { color: var(--green2); }
.feed-reason { color: var(--dim); font-size: 10px; }
.feed-footer {
padding: 7px 14px;
border-top: 1px solid var(--border);
font-size: 10px;
color: var(--dim);
display: flex;
justify-content: space-between;
}
/* Blinking cursor */
.cursor::after { content: '█'; animation: blink 1s step-end infinite; }
/* ── Attackers table ────────────────────────────────────────────────── */
.atk-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.atk-table th {
padding: 7px 14px;
text-align: left;
color: var(--amber);
font-size: 10px;
letter-spacing: 2px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.atk-table td { padding: 6px 14px; border-bottom: 1px solid var(--muted); }
.atk-table tr:hover td { background: var(--muted); }
.atk-rank { color: var(--dim); }
.atk-ip { color: var(--amber); font-weight: bold; }
.atk-hits { color: var(--green); font-weight: bold; }
.mini-bar { height: 8px; background: var(--muted); max-width: 240px; width: 100%; }
.mini-fill { background: var(--green); height: 100%; box-shadow: 0 0 4px var(--green); transition: width .5s; }
/* ── Footer ─────────────────────────────────────────────────────────── */
footer {
border-top: 1px solid var(--border);
padding: 9px 20px;
font-size: 10px;
color: var(--dim);
display: flex;
justify-content: space-between;
letter-spacing: 1px;
flex-wrap: wrap;
gap: 6px;
}
/* ── Responsive ─────────────────────────────────────────────────────── */
@media (max-width: 1100px) {
.stats-row { grid-template-columns: repeat(3, 1fr); }
.content-grid { grid-template-columns: 1fr; }
.feed-panel { min-height: 350px; }
}
@media (max-width: 640px) {
.stats-row { grid-template-columns: 1fr 1fr; }
.bars-2col { grid-template-columns: 1fr; }
.bar-item { grid-template-columns: 110px 1fr 45px; }
}
</style>
</head>
<body>
<header>
<div class="logo glow">[HONEYPOT<em>]</em> // NETWORK THREAT INTELLIGENCE</div>
<div class="header-right">
<span id="clock">--:--:--</span>
<span><span class="live-dot"></span>LIVE FEED</span>
</div>
</header>
<main>
<!-- ── Stats row ──────────────────────────────────────────────────── -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-num glow" id="s-total"></div>
<div class="stat-lbl">Total Blocked</div>
</div>
<div class="stat-card">
<div class="stat-num glow" id="s-today"></div>
<div class="stat-lbl">Today</div>
</div>
<div class="stat-card">
<div class="stat-num" id="s-7d"></div>
<div class="stat-lbl">Last 7 Days</div>
</div>
<div class="stat-card">
<div class="stat-num" id="s-30d"></div>
<div class="stat-lbl">Last 30 Days</div>
</div>
<div class="stat-card">
<div class="stat-num glow" id="s-sites"></div>
<div class="stat-lbl">Sites Reporting</div>
</div>
</div>
<!-- ── Content grid ───────────────────────────────────────────────── -->
<div class="content-grid">
<div class="left-col">
<!-- 24h chart -->
<div class="panel">
<div class="panel-hdr">
<span>▶ 24H ACTIVITY TREND</span>
<span id="chart-peak" style="color:var(--dim);font-size:11px"></span>
</div>
<div class="panel-body" style="padding:10px 12px">
<canvas id="chart"></canvas>
</div>
</div>
<!-- Bar charts -->
<div class="panel">
<div class="panel-hdr">▶ ATTACK BREAKDOWN // LAST 30 DAYS</div>
<div class="panel-body">
<div class="bars-2col">
<div>
<div class="bar-section-title">FORM TYPES</div>
<ul class="bar-list" id="bars-forms"></ul>
</div>
<div>
<div class="bar-section-title">BOT TOOLKIT</div>
<ul class="bar-list" id="bars-ua"></ul>
</div>
</div>
</div>
</div>
<!-- Block reasons -->
<div class="panel">
<div class="panel-hdr">▶ BLOCK REASONS // LAST 30 DAYS</div>
<div class="panel-body">
<ul class="bar-list" id="bars-reasons"></ul>
</div>
</div>
</div><!-- /.left-col -->
<!-- Live feed -->
<div class="panel feed-panel">
<div class="panel-hdr">
<span>▶ LIVE THREAT FEED</span>
<span id="feed-count" style="color:var(--dim);font-size:11px">0 events</span>
</div>
<div id="feed"></div>
<div class="feed-footer">
<span class="cursor"></span>
<span id="feed-status" style="color:var(--dim)">connecting…</span>
</div>
</div>
</div><!-- /.content-grid -->
<!-- ── Top Attackers ──────────────────────────────────────────────── -->
<div class="panel" style="margin-bottom:10px">
<div class="panel-hdr">▶ TOP ATTACKERS // LAST 30 DAYS</div>
<div style="overflow-x:auto">
<table class="atk-table">
<thead>
<tr>
<th style="width:50px">RANK</th>
<th>IP ADDRESS</th>
<th style="width:110px">TOTAL HITS</th>
<th>FREQUENCY</th>
</tr>
</thead>
<tbody id="atk-body">
<tr><td colspan="4" style="text-align:center;padding:20px;color:var(--dim)">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</main>
<footer>
<span>HONEYPOT NETWORK MONITOR // ANONYMOUS THREAT INTELLIGENCE // ALL DATA IS ANONYMISED</span>
<span>REFRESHED: <span id="last-update">--</span></span>
</footer>
<script>
// ── Clock ─────────────────────────────────────────────────────────────────────
const clockEl = document.getElementById('clock');
function tick() {
const d = new Date();
clockEl.textContent = d.toISOString().slice(11,19) + ' UTC';
}
tick(); setInterval(tick, 1000);
// ── CountUp animation ─────────────────────────────────────────────────────────
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);
}
// ── Bar charts ────────────────────────────────────────────────────────────────
function renderBars(listEl, items) {
if (!items || !items.length) {
listEl.innerHTML = '<li style="color:var(--dim);font-size:11px;padding:4px 0">No data yet</li>';
return;
}
const max = items[0].hits;
listEl.innerHTML = items.map(item => {
const label = item.form_type || item.ua_family || item.reason || 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" style="width:${pct}%"></div></div>
<span class="bar-cnt">${item.hits.toLocaleString()}</span>
</li>`;
}).join('');
}
// ── 24h Canvas Chart ──────────────────────────────────────────────────────────
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
function drawChart(hourly) {
const W = canvas.offsetWidth || 600;
const H = 80;
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 = '#005500';
ctx.font = '12px Courier New';
ctx.fillText('No activity in last 24h', 10, 44);
return;
}
// Fill all 24 hours (missing = 0)
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: 6, b: 4 };
const cW = W - pad.l - pad.r;
const cH = H - pad.t - pad.b;
const bW = cW / hrs.length - 1;
// Grid lines
ctx.strokeStyle = '#001800';
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();
});
// Bars
hrs.forEach((h, i) => {
const x = pad.l + i * (cW / hrs.length);
const bH = Math.max(1, (h.n / max) * cH);
const y = pad.t + cH - bH;
const grad = ctx.createLinearGradient(0, y, 0, y + bH);
grad.addColorStop(0, '#00ff41');
grad.addColorStop(1, '#004400');
ctx.fillStyle = grad;
ctx.fillRect(x + 0.5, y, Math.max(bW, 1), bH);
if (h.n === max) {
ctx.shadowColor = '#00ff41'; 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); });
// ── Attackers table ───────────────────────────────────────────────────────────
function renderAttackers(ips) {
const tbody = document.getElementById('atk-body');
if (!ips || !ips.length) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;padding:20px;color:var(--dim)">No data yet</td></tr>';
return;
}
const max = ips[0].hits;
tbody.innerHTML = ips.map((row, i) => `
<tr>
<td class="atk-rank">#${i + 1}</td>
<td class="atk-ip">${esc(row.ip)}</td>
<td class="atk-hits">${row.hits.toLocaleString()}</td>
<td><div class="mini-bar"><div class="mini-fill" style="width:${Math.round(row.hits/max*100)}%"></div></div></td>
</tr>`).join('');
}
// ── Helpers ───────────────────────────────────────────────────────────────────
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);
}
// ── Live feed ─────────────────────────────────────────────────────────────────
let feedCount = 0;
let autoScroll = true;
const feedEl = document.getElementById('feed');
const feedCount$ = document.getElementById('feed-count');
const feedStatus = document.getElementById('feed-status');
feedEl.addEventListener('mouseenter', () => autoScroll = false);
feedEl.addEventListener('mouseleave', () => { autoScroll = true; });
function addRow(row) {
feedCount++;
feedCount$.textContent = `${feedCount.toLocaleString()} events`;
const el = document.createElement('div');
el.className = 'feed-row';
el.innerHTML = `
<span class="feed-ts">${fmtTime(row.received_at)}</span>
<span class="feed-ip">${esc(row.ip_masked || row.ip || '?')}</span>
<span>
<span class="feed-form">${esc(row.form_type || '?')}</span>
<br><span class="feed-reason">${esc(row.reason || '')}</span>
</span>`;
feedEl.prepend(el);
while (feedEl.children.length > 120) feedEl.removeChild(feedEl.lastChild);
}
// Seed from recent history on load
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 (e) { console.warn('[seed]', e); }
}
// SSE for live updates
function connectSSE() {
const es = new EventSource('/api/v1/stream');
es.onopen = () => { feedStatus.textContent = 'connected'; };
es.onmessage = e => {
try { JSON.parse(e.data).reverse().forEach(addRow); } catch {}
};
es.onerror = () => {
es.close();
feedStatus.textContent = 'reconnecting…';
setTimeout(connectSSE, 5000);
};
}
// ── Stats polling ─────────────────────────────────────────────────────────────
async function fetchStats() {
try {
const r = await fetch('/api/v1/stats');
if (!r.ok) throw new Error(r.status);
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-forms'), s.top_forms);
renderBars(document.getElementById('bars-ua'), s.top_ua);
renderBars(document.getElementById('bars-reasons'), s.top_reasons);
renderAttackers(s.top_ips);
window._hourly = s.hourly;
drawChart(s.hourly);
document.getElementById('last-update').textContent =
new Date().toISOString().slice(11, 19) + ' UTC';
} catch (e) { console.error('[stats]', e); }
}
// ── Boot ──────────────────────────────────────────────────────────────────────
seedFeed();
connectSSE();
fetchStats();
setInterval(fetchStats, 6000);
</script>
</body>
</html>

237
api/server.js Normal file
View File

@@ -0,0 +1,237 @@
'use strict';
const express = require('express');
const Database = require('better-sqlite3');
const path = require('path');
const app = express();
const PORT = Number(process.env.PORT) || 3000;
const DB = new Database(process.env.DB_PATH || '/data/honeypot.db');
// ── Database setup ────────────────────────────────────────────────────────────
DB.pragma('journal_mode = WAL');
DB.pragma('synchronous = NORMAL');
DB.pragma('cache_size = -8000');
DB.exec(`
CREATE TABLE IF NOT EXISTS blocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
received_at INTEGER NOT NULL DEFAULT (unixepoch()),
site_id TEXT NOT NULL DEFAULT '',
ip_masked TEXT NOT NULL DEFAULT '',
form_type TEXT NOT NULL DEFAULT '',
reason TEXT NOT NULL DEFAULT '',
ua_family 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()),
block_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_recv ON blocks(received_at DESC);
CREATE INDEX IF NOT EXISTS idx_ip ON blocks(ip_masked);
CREATE INDEX IF NOT EXISTS idx_site ON blocks(site_id);
`);
// ── Helpers ───────────────────────────────────────────────────────────────────
function maskIP(ip = '') {
ip = String(ip).trim();
if (!ip) return 'x.x.x.x';
if (ip.includes(':')) { // IPv6 — keep first 2 groups
const p = ip.split(':');
return `${p[0]||'x'}:${p[1]||'x'}:…:x`;
}
const p = ip.split('.'); // IPv4 — keep first 2 octets
return p.length === 4 ? `${p[0]}.${p[1]}.x.x` : 'x.x.x.x';
}
const UA_MAP = [
[/curl\//i, 'curl'],
[/python-requests|python\//i, 'Python'],
[/go-http-client/i, 'Go'],
[/wget\//i, 'Wget'],
[/java\//i, 'Java'],
[/ruby/i, 'Ruby'],
[/perl\//i, 'Perl'],
[/php\//i, 'PHP'],
[/scrapy/i, 'Scrapy'],
[/postman/i, 'Postman'],
[/axios/i, 'Axios'],
[/node-fetch|node\.js/i, 'Node.js'],
[/headlesschrome|phantomjs/i, 'Headless Browser'],
[/(bot|crawler|spider|slurp)/i, 'Bot/Crawler'],
[/chrome/i, 'Chrome'],
[/firefox/i, 'Firefox'],
[/safari/i, 'Safari'],
[/edge|edg\//i, 'Edge'],
[/opera|opr\//i, 'Opera'],
[/msie|trident/i, 'IE'],
];
function parseUA(ua = '') {
for (const [re, label] of UA_MAP) if (re.test(ua)) return label;
return ua.length ? 'Other' : 'No UA';
}
// ── In-memory 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 blocks').get().n,
today: DB.prepare('SELECT COUNT(*) n FROM blocks WHERE received_at > ?').get(now - 86400).n,
last_7d: DB.prepare('SELECT COUNT(*) n FROM blocks WHERE received_at > ?').get(now - 604800).n,
last_30d: DB.prepare('SELECT COUNT(*) n FROM blocks WHERE received_at > ?').get(now - 2592000).n,
total_sites: DB.prepare('SELECT COUNT(*) n FROM sites').get().n,
top_ips: DB.prepare(`
SELECT ip_masked ip, COUNT(*) hits
FROM blocks WHERE received_at > ?
GROUP BY ip_masked ORDER BY hits DESC LIMIT 10
`).all(now - 2592000),
top_forms: DB.prepare(`
SELECT form_type, COUNT(*) hits
FROM blocks WHERE received_at > ?
GROUP BY form_type ORDER BY hits DESC LIMIT 8
`).all(now - 2592000),
top_reasons: DB.prepare(`
SELECT reason, COUNT(*) hits
FROM blocks WHERE received_at > ?
GROUP BY reason ORDER BY hits DESC LIMIT 8
`).all(now - 2592000),
top_ua: DB.prepare(`
SELECT ua_family, COUNT(*) hits
FROM blocks WHERE received_at > ?
GROUP BY ua_family ORDER BY hits DESC LIMIT 8
`).all(now - 2592000),
recent: DB.prepare(`
SELECT received_at, ip_masked ip, form_type, reason, ua_family
FROM blocks ORDER BY id DESC LIMIT 40
`).all(),
hourly: DB.prepare(`
SELECT (received_at / 3600) * 3600 h, COUNT(*) n
FROM blocks 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 blocks').get().id || 0;
setInterval(() => {
if (!sseClients.size) return;
const rows = DB.prepare('SELECT * FROM blocks 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 blocks (received_at, site_id, ip_masked, form_type, reason, ua_family) VALUES (?,?,?,?,?,?)'
);
const stmtSite = DB.prepare(`
INSERT INTO sites (site_id, first_seen, last_seen, block_count) VALUES (?,?,?,?)
ON CONFLICT(site_id) DO UPDATE SET
last_seen = excluded.last_seen,
block_count = block_count + excluded.block_count
`);
const insertBatch = DB.transaction((siteId, blocks) => {
const now = Math.floor(Date.now() / 1000);
for (const b of blocks) {
const ts = b.blocked_at ? Math.floor(new Date(b.blocked_at) / 1000) : now;
stmtIns.run(
ts, siteId,
maskIP(b.ip),
String(b.form_type || '').slice(0, 100),
String(b.reason || '').slice(0, 255),
parseUA(b.user_agent || '')
);
}
stmtSite.run(siteId, now, now, blocks.length);
});
// ── Express routes ────────────────────────────────────────────────────────────
app.use(express.json({ limit: '128kb' }));
app.use(express.static(path.join(__dirname, 'public')));
// Submit blocks from a WordPress site
app.post('/api/v1/submit', (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, blocks } = 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(blocks) || !blocks.length || blocks.length > 50) {
return res.status(400).json({ error: 'blocks must be array of 150 items' });
}
try {
insertBatch(site_hash.slice(0, 20), blocks);
_cache = null; // invalidate stats cache
res.json({ ok: true, received: blocks.length });
} catch (e) {
console.error('[submit]', e.message);
res.status(500).json({ error: 'Internal error' });
}
});
// Public aggregated stats
app.get('/api/v1/stats', (_, res) => res.json(getStats()));
// SSE live feed
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'); // flush headers
sseClients.add(res);
req.on('close', () => sseClients.delete(res));
});
// Health check
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(`[honeypot-api] listening on :${PORT}`);
console.log(`[honeypot-api] db: ${process.env.DB_PATH || '/data/honeypot.db'}`);
});