feat: initial Bot Intelligence API Docker stack
Self-hosted Node.js/Express + SQLite (WAL) API server and dashboard for tracking blocked bots and user agents. Features: - POST /api/v1/submit — batch ingest from WordPress plugin - GET /api/v1/stats — aggregated stats with 30s cache - GET /api/v1/stream — SSE live event feed - GET /api/v1/health — health check endpoint - Cyan/blue terminal-style dashboard with live feed, bar charts, 24h activity - Docker Compose setup on port 3001 with persistent SQLite volume - Bearer token auth with constant-time comparison 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=3001
|
||||||
|
DB_PATH=/data/bots.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 ci --omit=dev
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN mkdir -p /data
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
VOLUME ["/data"]
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
51
README.md
Normal file
51
README.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Bot Intelligence API
|
||||||
|
|
||||||
|
Centralized bot-blocking telemetry server for the InformatiQ Toolkit WordPress plugin.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env and set a strong API_TOKEN
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Dashboard available at `http://your-server:3001/`
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| POST | `/api/v1/submit` | Bearer token | Submit a batch of bot events (max 50) |
|
||||||
|
| GET | `/api/v1/stats` | — | Aggregated statistics (cached 30s) |
|
||||||
|
| GET | `/api/v1/stream` | — | SSE live event stream |
|
||||||
|
| GET | `/api/v1/health` | — | Health check |
|
||||||
|
|
||||||
|
## Submit payload
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"site_hash": "sha256-of-home-url",
|
||||||
|
"bots": [
|
||||||
|
{
|
||||||
|
"ip": "1.2.3.4",
|
||||||
|
"bot_type": "malicious_bot",
|
||||||
|
"action": "blocked",
|
||||||
|
"reason": "Malicious bot detected",
|
||||||
|
"user_agent": "BadBot/1.0",
|
||||||
|
"request_uri": "/wp-login.php",
|
||||||
|
"logged_at": "2026-01-01 12:00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`action` is one of: `blocked`, `rate_limited`
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `PORT` | `3001` | Listen port |
|
||||||
|
| `DB_PATH` | `/data/bots.db` | SQLite database path |
|
||||||
|
| `API_TOKEN` | _(empty)_ | Bearer token (leave empty for open/dev mode) |
|
||||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
services:
|
||||||
|
bot-api:
|
||||||
|
build: .
|
||||||
|
container_name: bot-api
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
volumes:
|
||||||
|
- bot-data:/data
|
||||||
|
environment:
|
||||||
|
- PORT=3001
|
||||||
|
- DB_PATH=/data/bots.db
|
||||||
|
- NODE_ENV=production
|
||||||
|
- API_TOKEN=${API_TOKEN:-change-me-to-a-long-random-string}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/v1/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
bot-data:
|
||||||
|
driver: local
|
||||||
17
package.json
Normal file
17
package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "bot-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Centralized bot intelligence 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
738
public/index.html
Normal file
738
public/index.html
Normal file
@@ -0,0 +1,738 @@
|
|||||||
|
<!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>BOT // NETWORK INTELLIGENCE</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #00060d;
|
||||||
|
--bg2: #010c18;
|
||||||
|
--cyan: #00d4ff;
|
||||||
|
--cyan2: #00aacc;
|
||||||
|
--dim: #3399bb;
|
||||||
|
--dim2: #1a6688;
|
||||||
|
--muted: #001a28;
|
||||||
|
--border: #003a55;
|
||||||
|
--red: #ff4040;
|
||||||
|
--amber: #ffaa00;
|
||||||
|
--white: #d4f4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::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(--cyan);
|
||||||
|
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,.06) 2px, rgba(0,0,0,.06) 4px
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow { text-shadow: 0 0 12px var(--cyan), 0 0 24px var(--cyan); }
|
||||||
|
.glow-sm { text-shadow: 0 0 6px var(--cyan); }
|
||||||
|
|
||||||
|
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(--cyan);
|
||||||
|
}
|
||||||
|
.logo em { color: var(--amber); font-style: normal; }
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--cyan2);
|
||||||
|
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(--cyan); box-shadow: 0 0 8px var(--cyan); }
|
||||||
|
|
||||||
|
.stat-num {
|
||||||
|
font-size: 26px; font-weight: bold;
|
||||||
|
letter-spacing: 2px; line-height: 1.1;
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
.stat-lbl {
|
||||||
|
font-size: 9px; letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--dim);
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#top-target {
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid #001a33;
|
||||||
|
border-left: 3px solid var(--amber);
|
||||||
|
padding: 7px 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
#top-target .tt-label { font-size: 10px; letter-spacing: 2px; color: var(--dim2); }
|
||||||
|
#top-target .tt-form { font-size: 14px; font-weight: bold; color: var(--amber); text-shadow: 0 0 10px var(--amber); }
|
||||||
|
#top-target .tt-hits { font-size: 11px; color: var(--cyan2); }
|
||||||
|
#top-target .tt-pct { font-size: 11px; color: var(--dim); }
|
||||||
|
|
||||||
|
.content-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 420px;
|
||||||
|
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: 140px 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(--cyan); 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 {
|
||||||
|
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.6;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
#feed::-webkit-scrollbar { width: 3px; }
|
||||||
|
#feed::-webkit-scrollbar-track { background: var(--bg); }
|
||||||
|
#feed::-webkit-scrollbar-thumb { background: var(--dim2); }
|
||||||
|
|
||||||
|
.feed-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 58px 110px auto;
|
||||||
|
gap: 5px;
|
||||||
|
border-bottom: 1px solid var(--muted);
|
||||||
|
padding: 1px 0;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.feed-ts { color: var(--dim2); }
|
||||||
|
.feed-ip { color: var(--amber); }
|
||||||
|
.feed-bot { color: var(--cyan2); }
|
||||||
|
.feed-action { font-weight: bold; }
|
||||||
|
.feed-action.blocked { color: var(--red); }
|
||||||
|
.feed-action.rate_limited { color: var(--amber); }
|
||||||
|
.feed-reason { color: var(--dim); font-size: 10px; }
|
||||||
|
.feed-geo { color: var(--dim); font-size: 10px; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
.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(--cyan); 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(--cyan); height: 100%; box-shadow: 0 0 4px var(--cyan); 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(--cyan2); }
|
||||||
|
.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">[BOT<em>]</em> // NETWORK 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 BLOCKED</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-rl">…</div>
|
||||||
|
<div class="stat-lbl" data-i18n="stat_rl">RATE LIMITED</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-num glow" id="s-sites">…</div>
|
||||||
|
<div class="stat-lbl" data-i18n="stat_sites">SITES REPORTING</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="top-target">
|
||||||
|
<span class="tt-label" data-i18n="top_target_label">▶ MOST ACTIVE BOT 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="breakdown_title">▶ BOT BREAKDOWN // LAST 30 DAYS</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="bars-2col">
|
||||||
|
<div>
|
||||||
|
<div class="bar-section-title" data-i18n="bot_types">BOT TYPES</div>
|
||||||
|
<ul class="bar-list" id="bars-bots"></ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="bar-section-title" data-i18n="ua_family">UA FAMILIES</div>
|
||||||
|
<ul class="bar-list" id="bars-ua"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-hdr" data-i18n="actions_title">▶ ACTIONS + REASONS // LAST 30 DAYS</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="bars-2col">
|
||||||
|
<div>
|
||||||
|
<div class="bar-section-title" data-i18n="actions">ACTIONS</div>
|
||||||
|
<ul class="bar-list" id="bars-actions"></ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="bar-section-title" data-i18n="reasons">REASONS</div>
|
||||||
|
<ul class="bar-list" id="bars-reasons"></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 BOT 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="attackers_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_hits">HITS</th>
|
||||||
|
<th data-i18n="col_asn">AS</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="atk-body">
|
||||||
|
<tr><td colspan="4" 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">BOT NETWORK MONITOR // CENTRALIZED THREAT INTELLIGENCE</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>
|
||||||
|
const I18N = {
|
||||||
|
en: {
|
||||||
|
live_feed:'LIVE FEED', stat_total:'TOTAL BLOCKED', stat_today:'TODAY',
|
||||||
|
stat_7d:'LAST 7 DAYS', stat_30d:'LAST 30 DAYS', stat_rl:'RATE LIMITED',
|
||||||
|
stat_sites:'SITES REPORTING', top_target_label:'▶ MOST ACTIVE BOT TYPE (30D):',
|
||||||
|
chart_title:'▶ 24H ACTIVITY TREND', breakdown_title:'▶ BOT BREAKDOWN // LAST 30 DAYS',
|
||||||
|
bot_types:'BOT TYPES', ua_family:'UA FAMILIES',
|
||||||
|
actions_title:'▶ ACTIONS + REASONS // LAST 30 DAYS', actions:'ACTIONS', reasons:'REASONS',
|
||||||
|
feed_title:'▶ LIVE BOT FEED', events:'events',
|
||||||
|
connecting:'connecting…', connected:'connected', reconnecting:'reconnecting…',
|
||||||
|
attackers_title:'▶ TOP OFFENDERS // LAST 30 DAYS',
|
||||||
|
col_ip:'IP ADDRESS', col_hits:'HITS', col_asn:'AS',
|
||||||
|
loading:'Loading…', no_data:'No data yet',
|
||||||
|
footer_copy:'BOT NETWORK MONITOR // CENTRALIZED THREAT INTELLIGENCE',
|
||||||
|
refreshed:'REFRESHED:', made_in_eu:'🇪🇺 Made & hosted in the EU by',
|
||||||
|
},
|
||||||
|
es: {
|
||||||
|
live_feed:'EN VIVO', stat_total:'TOTAL BLOQUEADOS', stat_today:'HOY',
|
||||||
|
stat_7d:'ÚLTIMOS 7 DÍAS', stat_30d:'ÚLTIMOS 30 DÍAS', stat_rl:'LIMITADOS',
|
||||||
|
stat_sites:'SITIOS REPORTANDO', top_target_label:'▶ BOT MÁS ACTIVO (30D):',
|
||||||
|
chart_title:'▶ TENDENCIA 24H', breakdown_title:'▶ DESGLOSE // ÚLTIMOS 30 DÍAS',
|
||||||
|
bot_types:'TIPOS DE BOT', ua_family:'FAMILIAS UA',
|
||||||
|
actions_title:'▶ ACCIONES + MOTIVOS // ÚLTIMOS 30 DÍAS', actions:'ACCIONES', reasons:'MOTIVOS',
|
||||||
|
feed_title:'▶ FEED EN VIVO', events:'eventos',
|
||||||
|
connecting:'conectando…', connected:'conectado', reconnecting:'reconectando…',
|
||||||
|
attackers_title:'▶ TOP OFENSORES // ÚLTIMOS 30 DÍAS',
|
||||||
|
col_ip:'DIRECCIÓN IP', col_hits:'IMPACTOS', col_asn:'AS',
|
||||||
|
loading:'Cargando…', no_data:'Sin datos aún',
|
||||||
|
footer_copy:'MONITOR BOT // INTELIGENCIA CENTRALIZADA DE AMENAZAS',
|
||||||
|
refreshed:'ACTUALIZADO:', made_in_eu:'🇪🇺 Hecho y alojado en la UE por',
|
||||||
|
},
|
||||||
|
ro: {
|
||||||
|
live_feed:'LIVE', stat_total:'TOTAL BLOCATE', stat_today:'AZI',
|
||||||
|
stat_7d:'ULTIMELE 7 ZILE', stat_30d:'ULTIMELE 30 ZILE', stat_rl:'LIMITATE',
|
||||||
|
stat_sites:'SITE-URI RAPORTÂND', top_target_label:'▶ BOT CEL MAI ACTIV (30Z):',
|
||||||
|
chart_title:'▶ TENDINȚĂ 24H', breakdown_title:'▶ ANALIZĂ // ULTIMELE 30 ZILE',
|
||||||
|
bot_types:'TIPURI BOT', ua_family:'FAMILII UA',
|
||||||
|
actions_title:'▶ ACȚIUNI + MOTIVE // ULTIMELE 30 ZILE', actions:'ACȚIUNI', reasons:'MOTIVE',
|
||||||
|
feed_title:'▶ FLUX LIVE BOȚI', events:'evenimente',
|
||||||
|
connecting:'conectare…', connected:'conectat', reconnecting:'reconectare…',
|
||||||
|
attackers_title:'▶ TOP OFENSATORI // ULTIMELE 30 ZILE',
|
||||||
|
col_ip:'ADRESĂ IP', col_hits:'ACCESĂRI', col_asn:'AS',
|
||||||
|
loading:'Se încarcă…', no_data:'Fără date încă',
|
||||||
|
footer_copy:'MONITOR BOT // INFORMAȚII CENTRALIZATE DESPRE AMENINȚĂRI',
|
||||||
|
refreshed:'ACTUALIZAT:', made_in_eu:'🇪🇺 Realizat și găzduit în UE de',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function detectLang() {
|
||||||
|
const s = localStorage.getItem('bot_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('bot_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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function flag(cc) {
|
||||||
|
if (!cc || cc.length !== 2) return '';
|
||||||
|
return String.fromCodePoint(...[...cc.toUpperCase()].map(c => c.charCodeAt(0) + 127397));
|
||||||
|
}
|
||||||
|
|
||||||
|
const clockEl = document.getElementById('clock');
|
||||||
|
function tick() { clockEl.textContent = new Date().toISOString().slice(11,19) + ' UTC'; }
|
||||||
|
tick(); setInterval(tick, 1000);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.bot_type || item.ua_family || item.action || 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 ${fillClass}" style="width:${pct}%"></div></div>
|
||||||
|
<span class="bar-cnt">${item.hits.toLocaleString()}</span>
|
||||||
|
</li>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
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='#226644'; 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='#001a28'; 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,'#00d4ff'); g.addColorStop(1,'#003344');
|
||||||
|
ctx.fillStyle=g; ctx.fillRect(x+0.5,y,Math.max(bW,1),bH);
|
||||||
|
if (h.n===max) {
|
||||||
|
ctx.shadowColor='#00d4ff'; 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); });
|
||||||
|
|
||||||
|
function renderAttackers(ips) {
|
||||||
|
const tbody = document.getElementById('atk-body');
|
||||||
|
if (!ips||!ips.length) {
|
||||||
|
tbody.innerHTML=`<tr><td colspan="4" style="text-align:center;padding:14px;color:var(--dim)">${t('no_data')}</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const max = ips[0].hits;
|
||||||
|
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">${f?f+' ':''}${esc(row.ip)}</td>
|
||||||
|
<td class="atk-hits">${row.hits.toLocaleString()}</td>
|
||||||
|
<td class="atk-asn" title="${esc(row.asn||'')}">${esc(asnNo)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
}
|
||||||
|
function fmtTime(ts) { return new Date(ts*1000).toISOString().slice(11,19); }
|
||||||
|
|
||||||
|
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 action = row.action||'blocked';
|
||||||
|
el.innerHTML = `
|
||||||
|
<span class="feed-ts">${fmtTime(row.received_at)}</span>
|
||||||
|
<span class="feed-ip">${esc(row.ip_masked||row.ip||'?')}</span>
|
||||||
|
<span>
|
||||||
|
${f?`<span class="feed-geo">${f} ${esc(row.country||'')}</span><br>`:''}
|
||||||
|
<span class="feed-bot">${esc(row.bot_type||'?')}</span>
|
||||||
|
<span class="feed-action ${action}"> [${esc(action)}]</span>
|
||||||
|
<br><span class="feed-reason">${esc(row.reason||row.ua_family||'')}</span>
|
||||||
|
</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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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-rl'), s.rate_limited||0);
|
||||||
|
countUp(document.getElementById('s-sites'), s.total_sites);
|
||||||
|
|
||||||
|
renderBars(document.getElementById('bars-bots'), s.top_bot_types, 'bot_type');
|
||||||
|
renderBars(document.getElementById('bars-ua'), s.top_ua, 'ua_family');
|
||||||
|
renderBars(document.getElementById('bars-actions'), s.top_actions, 'action', 'bar-fill-amber');
|
||||||
|
renderBars(document.getElementById('bars-reasons'), s.top_reasons, 'reason');
|
||||||
|
renderAttackers(s.top_ips);
|
||||||
|
|
||||||
|
if (s.top_bot_types && s.top_bot_types.length) {
|
||||||
|
const top = s.top_bot_types[0];
|
||||||
|
document.getElementById('tt-form').textContent = top.bot_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 blocks)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
window._hourly = s.hourly;
|
||||||
|
drawChart(s.hourly);
|
||||||
|
document.getElementById('last-update').textContent = new Date().toISOString().slice(11,19)+' UTC';
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
feedStatus.textContent = t('connecting');
|
||||||
|
applyTranslations();
|
||||||
|
updateLangButtons();
|
||||||
|
seedFeed();
|
||||||
|
connectSSE();
|
||||||
|
fetchStats();
|
||||||
|
setInterval(fetchStats, 6000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
307
server.js
Normal file
307
server.js
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
'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) || 3001;
|
||||||
|
const DB = new Database(process.env.DB_PATH || '/data/bots.db');
|
||||||
|
|
||||||
|
// ── Database ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DB.pragma('journal_mode = WAL');
|
||||||
|
DB.pragma('synchronous = NORMAL');
|
||||||
|
DB.pragma('cache_size = -8000');
|
||||||
|
|
||||||
|
DB.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS bots (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
received_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||||
|
site_id TEXT NOT NULL DEFAULT '',
|
||||||
|
ip_masked TEXT NOT NULL DEFAULT '',
|
||||||
|
bot_type TEXT NOT NULL DEFAULT '',
|
||||||
|
action TEXT NOT NULL DEFAULT 'blocked',
|
||||||
|
reason TEXT NOT NULL DEFAULT '',
|
||||||
|
ua_family TEXT NOT NULL DEFAULT '',
|
||||||
|
request_uri 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()),
|
||||||
|
block_count INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recv ON bots(received_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ip ON bots(ip_masked);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_site ON bots(site_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_type ON bots(bot_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_action ON bots(action);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Migrations – silently ignored if columns already exist
|
||||||
|
['country', 'asn', 'request_uri'].forEach(col => {
|
||||||
|
try { DB.exec(`ALTER TABLE bots 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UA families ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const UA_MAP = [
|
||||||
|
[/curl\//i, 'curl'],
|
||||||
|
[/python-requests|python\//i, 'Python'],
|
||||||
|
[/go-http-client/i, 'Go'],
|
||||||
|
[/wget\//i, 'Wget'],
|
||||||
|
[/java\//i, 'Java'],
|
||||||
|
[/scrapy/i, 'Scrapy'],
|
||||||
|
[/axios/i, 'Axios'],
|
||||||
|
[/headlesschrome|phantomjs/i, 'Headless Browser'],
|
||||||
|
[/(bot|crawler|spider|slurp)/i, 'Bot/Crawler'],
|
||||||
|
[/GPTBot|ChatGPT/i, 'OpenAI Bot'],
|
||||||
|
[/Googlebot/i, 'Googlebot'],
|
||||||
|
[/bingbot/i, 'Bingbot'],
|
||||||
|
[/YandexBot/i, 'YandexBot'],
|
||||||
|
[/Baiduspider/i, 'Baiduspider'],
|
||||||
|
[/DuckDuckBot/i, 'DuckDuckBot'],
|
||||||
|
[/AhrefsBot/i, 'AhrefsBot'],
|
||||||
|
[/SemrushBot/i, 'SemrushBot'],
|
||||||
|
[/chrome/i, 'Chrome'],
|
||||||
|
[/firefox/i, 'Firefox'],
|
||||||
|
[/safari/i, 'Safari'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseUA(ua = '') {
|
||||||
|
for (const [re, label] of UA_MAP) if (re.test(ua)) return label;
|
||||||
|
return ua.length ? 'Other' : 'No UA';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IP geo-enrichment ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const stmtEnrich = DB.prepare('UPDATE bots 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_masked FROM bots WHERE country='' AND ip_masked != '' AND ip_masked != '?' LIMIT 5"
|
||||||
|
);
|
||||||
|
setInterval(() => {
|
||||||
|
for (const row of stmtUnenriched.all()) enrichIP(row.id, row.ip_masked);
|
||||||
|
}, 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 bots').get().n,
|
||||||
|
today: DB.prepare('SELECT COUNT(*) n FROM bots WHERE received_at > ?').get(now - 86400).n,
|
||||||
|
last_7d: DB.prepare('SELECT COUNT(*) n FROM bots WHERE received_at > ?').get(now - 604800).n,
|
||||||
|
last_30d: DB.prepare('SELECT COUNT(*) n FROM bots WHERE received_at > ?').get(now - 2592000).n,
|
||||||
|
rate_limited: DB.prepare("SELECT COUNT(*) n FROM bots WHERE action='rate_limited' AND 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, country, asn, COUNT(*) hits
|
||||||
|
FROM bots WHERE received_at > ?
|
||||||
|
GROUP BY ip_masked ORDER BY hits DESC LIMIT 10
|
||||||
|
`).all(now - 2592000),
|
||||||
|
top_bot_types: DB.prepare(`
|
||||||
|
SELECT bot_type, COUNT(*) hits
|
||||||
|
FROM bots WHERE received_at > ?
|
||||||
|
GROUP BY bot_type ORDER BY hits DESC LIMIT 8
|
||||||
|
`).all(now - 2592000),
|
||||||
|
top_actions: DB.prepare(`
|
||||||
|
SELECT action, COUNT(*) hits
|
||||||
|
FROM bots WHERE received_at > ?
|
||||||
|
GROUP BY action ORDER BY hits DESC LIMIT 8
|
||||||
|
`).all(now - 2592000),
|
||||||
|
top_reasons: DB.prepare(`
|
||||||
|
SELECT reason, COUNT(*) hits
|
||||||
|
FROM bots 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 bots 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, country, bot_type, action, reason, ua_family
|
||||||
|
FROM bots ORDER BY id DESC LIMIT 40
|
||||||
|
`).all(),
|
||||||
|
hourly: DB.prepare(`
|
||||||
|
SELECT (received_at / 3600) * 3600 h, COUNT(*) n
|
||||||
|
FROM bots 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 bots').get().id || 0;
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
if (!sseClients.size) return;
|
||||||
|
const rows = DB.prepare('SELECT * FROM bots 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 bots (received_at, site_id, ip_masked, bot_type, action, reason, ua_family, request_uri, country, asn)
|
||||||
|
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, bots) => {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const ids = [];
|
||||||
|
for (const b of bots) {
|
||||||
|
const ts = b.logged_at ? Math.floor(new Date(b.logged_at) / 1000) : now;
|
||||||
|
const ip = String(b.ip || '').trim().slice(0, 45) || '?';
|
||||||
|
const r = stmtIns.run(
|
||||||
|
ts, siteId, ip,
|
||||||
|
String(b.bot_type || '').slice(0, 100),
|
||||||
|
String(b.action || 'blocked').slice(0, 20),
|
||||||
|
String(b.reason || '').slice(0, 255),
|
||||||
|
parseUA(b.user_agent || ''),
|
||||||
|
String(b.request_uri || '').slice(0, 500),
|
||||||
|
'', '' // country/asn filled async
|
||||||
|
);
|
||||||
|
ids.push({ id: Number(r.lastInsertRowid), ip });
|
||||||
|
}
|
||||||
|
stmtSite.run(siteId, now, now, bots.length);
|
||||||
|
return ids;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.use(express.json({ limit: '128kb' }));
|
||||||
|
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, bots } = 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(bots) || !bots.length || bots.length > 50) {
|
||||||
|
return res.status(400).json({ error: 'bots must be array of 1–50 items' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ids = insertBatch(site_hash.slice(0, 20), bots);
|
||||||
|
_cache = null;
|
||||||
|
setImmediate(() => ids.forEach(({ id, ip }) => enrichIP(id, ip)));
|
||||||
|
res.json({ ok: true, received: bots.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(`[bot-api] listening on :${PORT}`);
|
||||||
|
console.log(`[bot-api] db: ${process.env.DB_PATH || '/data/bots.db'}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user