Files
HoneypotFields/api/server.js

252 lines
9.7 KiB
JavaScript
Raw Normal View History

'use strict';
const express = require('express');
const Database = require('better-sqlite3');
const path = require('path');
const { timingSafeEqual } = require('crypto');
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);
`);
// ── Auth token ────────────────────────────────────────────────────────────────
const API_TOKEN = (process.env.API_TOKEN || '').trim();
function requireToken(req, res, next) {
if (!API_TOKEN) return next(); // dev: no token set = open
const auth = req.headers['authorization'] || '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
// Constant-time comparison — pad both to 128 bytes to avoid length leaks
const a = Buffer.alloc(128); Buffer.from(token).copy(a, 0, 0, 128);
const b = Buffer.alloc(128); Buffer.from(API_TOKEN).copy(b, 0, 0, 128);
if (!timingSafeEqual(a, b) || token !== API_TOKEN) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function sanitizeIP(ip = '') {
return String(ip).trim().slice(0, 45) || '?';
}
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,
sanitizeIP(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 (token-protected)
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, 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'}`);
});