From 6740180981aecb0c04b25a2c7949519ef747c250 Mon Sep 17 00:00:00 2001 From: Malin Date: Mon, 9 Mar 2026 19:21:41 +0100 Subject: [PATCH] feat: centralized API dashboard + Docker container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/.env.example | 3 + api/Dockerfile | 17 + api/docker-compose.yml | 22 ++ api/package.json | 17 + api/public/index.html | 621 +++++++++++++++++++++++++++++++++++ api/server.js | 237 ++++++++++++++ honeypot-fields.php | 717 +++++++++++++++++++++++------------------ 7 files changed, 1328 insertions(+), 306 deletions(-) create mode 100644 api/.env.example create mode 100644 api/Dockerfile create mode 100644 api/docker-compose.yml create mode 100644 api/package.json create mode 100644 api/public/index.html create mode 100644 api/server.js diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..79b0dbb --- /dev/null +++ b/api/.env.example @@ -0,0 +1,3 @@ +PORT=3000 +DB_PATH=/data/honeypot.db +NODE_ENV=production diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..0432ad3 --- /dev/null +++ b/api/Dockerfile @@ -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"] diff --git a/api/docker-compose.yml b/api/docker-compose.yml new file mode 100644 index 0000000..f6caf3e --- /dev/null +++ b/api/docker-compose.yml @@ -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 diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..13c272b --- /dev/null +++ b/api/package.json @@ -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" + } +} diff --git a/api/public/index.html b/api/public/index.html new file mode 100644 index 0000000..1444e83 --- /dev/null +++ b/api/public/index.html @@ -0,0 +1,621 @@ + + + + + +HONEYPOT // NETWORK MONITOR + + + + +
+ +
+ --:--:-- + LIVE FEED +
+
+ +
+ + +
+
+
+
Total Blocked
+
+
+
+
Today
+
+
+
+
Last 7 Days
+
+
+
+
Last 30 Days
+
+
+
+
Sites Reporting
+
+
+ + +
+ +
+ + +
+
+ ▶ 24H ACTIVITY TREND + +
+
+ +
+
+ + +
+
▶ ATTACK BREAKDOWN // LAST 30 DAYS
+
+
+
+
FORM TYPES
+
    +
    +
    +
    BOT TOOLKIT
    +
      +
      +
      +
      +
      + + +
      +
      ▶ BLOCK REASONS // LAST 30 DAYS
      +
      +
        +
        +
        + +
        + + +
        +
        + ▶ LIVE THREAT FEED + 0 events +
        +
        + +
        + +
        + + +
        +
        ▶ TOP ATTACKERS // LAST 30 DAYS
        +
        + + + + + + + + + + + + +
        RANKIP ADDRESSTOTAL HITSFREQUENCY
        Loading…
        +
        +
        + +
        + + + + + + + diff --git a/api/server.js b/api/server.js new file mode 100644 index 0000000..6aeb140 --- /dev/null +++ b/api/server.js @@ -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 1–50 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'}`); +}); diff --git a/honeypot-fields.php b/honeypot-fields.php index 3a367f3..fea0236 100644 --- a/honeypot-fields.php +++ b/honeypot-fields.php @@ -3,7 +3,7 @@ * Plugin Name: Honeypot Fields * Plugin URI: https://informatiq.services * Description: Adds invisible honeypot fields to all forms to block spam bots. Works with WordPress core forms, Elementor, Gravity Forms, Contact Form 7, WooCommerce, and more. - * Version: 2.1.0 + * Version: 2.2.0 * Author: Malin * Author URI: https://malin.ro * License: GPL v2 or later @@ -19,7 +19,7 @@ if (!defined('ABSPATH')) { * ====================================================================*/ class SmartHoneypotDB { - const TABLE_VERSION = 1; + const TABLE_VERSION = 1; const TABLE_VERSION_OPTION = 'hp_db_version'; public static function table(): string { @@ -29,7 +29,7 @@ class SmartHoneypotDB { public static function install() { global $wpdb; - $table = self::table(); + $table = self::table(); $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$table} ( @@ -58,8 +58,8 @@ class SmartHoneypotDB { self::table(), [ 'blocked_at' => current_time('mysql'), - 'ip_address' => sanitize_text_field($data['ip'] ?? ''), - 'form_type' => sanitize_text_field($data['form'] ?? 'Unknown'), + 'ip_address' => sanitize_text_field($data['ip'] ?? ''), + 'form_type' => sanitize_text_field($data['form'] ?? 'Unknown'), 'reason' => sanitize_text_field($data['reason'] ?? ''), 'request_uri' => esc_url_raw(substr($data['uri'] ?? '', 0, 1000)), 'user_agent' => sanitize_textarea_field($data['ua'] ?? ''), @@ -72,7 +72,7 @@ class SmartHoneypotDB { global $wpdb; $table = self::table(); $limit = max(1, intval($args['per_page'] ?? 25)); - $offset = max(0, intval($args['offset'] ?? 0)); + $offset = max(0, intval($args['offset'] ?? 0)); $where = '1=1'; $params = []; @@ -97,12 +97,7 @@ class SmartHoneypotDB { $params[] = $offset; $sql = "SELECT * FROM {$table} WHERE {$where} ORDER BY blocked_at DESC LIMIT %d OFFSET %d"; - - if ($params) { - $sql = $wpdb->prepare($sql, $params); - } - - return $wpdb->get_results($sql) ?: []; + return $wpdb->get_results($wpdb->prepare($sql, $params)) ?: []; } public static function count(array $args = []): int { @@ -128,10 +123,7 @@ class SmartHoneypotDB { } $sql = "SELECT COUNT(*) FROM {$table} WHERE {$where}"; - if ($params) { - $sql = $wpdb->prepare($sql, $params); - } - return (int) $wpdb->get_var($sql); + return (int) $wpdb->get_var($params ? $wpdb->prepare($sql, $params) : $sql); } public static function get_form_types(): array { @@ -139,12 +131,12 @@ class SmartHoneypotDB { return $wpdb->get_col("SELECT DISTINCT form_type FROM " . self::table() . " ORDER BY form_type ASC") ?: []; } - public static function clear(): int { + public static function clear(): void { global $wpdb; - return (int) $wpdb->query("TRUNCATE TABLE " . self::table()); + $wpdb->query("TRUNCATE TABLE " . self::table()); } - public static function delete_older_than_days(int $days) { + public static function delete_older_than_days(int $days): void { global $wpdb; $wpdb->query( $wpdb->prepare( @@ -155,6 +147,85 @@ class SmartHoneypotDB { } } +/* ====================================================================== + * CENTRAL API CLIENT + * Queues blocked submissions and batch-sends to a central dashboard. + * ====================================================================*/ +class SmartHoneypotAPIClient { + + const OPT_SETTINGS = 'hp_api_settings'; + const OPT_QUEUE = 'hp_api_queue'; + const QUEUE_MAX = 500; + const BATCH_SIZE = 50; + + public static function defaults(): array { + return [ + 'enabled' => false, + 'api_url' => '', + 'last_sync' => 0, + 'sent_total' => 0, + ]; + } + + public static function settings(): array { + return wp_parse_args(get_option(self::OPT_SETTINGS, []), self::defaults()); + } + + /** Called from log_spam() — very fast, just appends to option. */ + public static function enqueue(array $data): void { + $s = self::settings(); + if (!$s['enabled'] || empty($s['api_url'])) { + return; + } + $queue = (array) get_option(self::OPT_QUEUE, []); + if (count($queue) >= self::QUEUE_MAX) { + array_shift($queue); // drop oldest when full + } + $queue[] = $data; + update_option(self::OPT_QUEUE, $queue, false); // no autoload + } + + /** Called by WP-cron every 5 minutes. Sends pending batch to the API. */ + public static function flush(): void { + $s = self::settings(); + if (!$s['enabled'] || empty($s['api_url'])) { + return; + } + $queue = (array) get_option(self::OPT_QUEUE, []); + if (empty($queue)) { + return; + } + + $batch = array_splice($queue, 0, self::BATCH_SIZE); + $site_hash = hash('sha256', home_url()); + + $response = wp_remote_post( + trailingslashit(esc_url_raw($s['api_url'])) . 'api/v1/submit', + [ + 'timeout' => 15, + 'blocking' => true, + 'headers' => ['Content-Type' => 'application/json'], + 'body' => wp_json_encode([ + 'site_hash' => $site_hash, + 'blocks' => $batch, + ]), + ] + ); + + if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) { + update_option(self::OPT_QUEUE, $queue, false); + $s['last_sync'] = time(); + $s['sent_total'] = ($s['sent_total'] ?? 0) + count($batch); + update_option(self::OPT_SETTINGS, $s); + } + } + + /** Number of items currently waiting to be sent. */ + public static function queue_size(): int { + return count((array) get_option(self::OPT_QUEUE, [])); + } +} + /* ====================================================================== * ADMIN PAGE * ====================================================================*/ @@ -165,16 +236,15 @@ class SmartHoneypotAdmin { const PER_PAGE = 25; public static function register() { - add_action('admin_menu', [self::class, 'add_menu']); - add_action('admin_init', [self::class, 'handle_actions']); - add_action('admin_enqueue_scripts', [self::class, 'enqueue_styles']); + add_action('admin_menu', [self::class, 'add_menu']); + add_action('admin_init', [self::class, 'handle_actions']); + add_action('admin_enqueue_scripts', [self::class, 'enqueue_styles']); add_filter('plugin_action_links_' . plugin_basename(HP_PLUGIN_FILE), [self::class, 'plugin_links']); } public static function plugin_links($links) { - $log_link = 'View Logs'; - array_unshift($links, $log_link); - array_push($links, 'Documentation'); + array_unshift($links, 'View Logs'); + $links[] = 'Documentation'; return $links; } @@ -194,39 +264,30 @@ class SmartHoneypotAdmin { if ($hook !== 'toplevel_page_' . self::MENU_SLUG) { return; } - // Inline styles — no external file needed - $css = ' - #hp-log-wrap { max-width: 1400px; } - #hp-log-wrap .hp-stats { display:flex; gap:16px; margin:16px 0; flex-wrap:wrap; } - #hp-log-wrap .hp-stat-card { - background:#fff; border:1px solid #c3c4c7; border-radius:4px; - padding:16px 24px; min-width:140px; text-align:center; - } - #hp-log-wrap .hp-stat-card .hp-stat-num { font-size:2em; font-weight:700; color:#2271b1; line-height:1.2; } - #hp-log-wrap .hp-stat-card .hp-stat-lbl { color:#646970; font-size:12px; } - #hp-log-wrap .hp-filters { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; } - #hp-log-wrap .hp-filters input, #hp-log-wrap .hp-filters select { height:32px; } - #hp-log-wrap table.hp-log-table { width:100%; border-collapse:collapse; background:#fff; } - #hp-log-wrap table.hp-log-table th { - background:#f0f0f1; padding:8px 12px; text-align:left; - border-bottom:2px solid #c3c4c7; white-space:nowrap; - } - #hp-log-wrap table.hp-log-table td { padding:8px 12px; border-bottom:1px solid #f0f0f1; vertical-align:top; } - #hp-log-wrap table.hp-log-table tr:hover td { background:#f6f7f7; } - #hp-log-wrap .hp-ua { font-size:11px; color:#646970; max-width:300px; word-break:break-all; } - #hp-log-wrap .hp-badge { - display:inline-block; padding:2px 8px; border-radius:3px; font-size:11px; font-weight:600; - background:#ffecec; color:#b32d2e; border:1px solid #f7c5c5; - } - #hp-log-wrap .hp-pagination { margin:12px 0; display:flex; align-items:center; gap:8px; } - #hp-log-wrap .hp-pagination a, #hp-log-wrap .hp-pagination span { - display:inline-block; padding:4px 10px; border:1px solid #c3c4c7; - border-radius:3px; background:#fff; text-decoration:none; color:#2271b1; - } - #hp-log-wrap .hp-pagination span.current { background:#2271b1; color:#fff; border-color:#2271b1; } - #hp-log-wrap .hp-clear-btn { color:#b32d2e; } - '; - wp_add_inline_style('common', $css); + wp_add_inline_style('common', ' + #hp-wrap { max-width:1400px; } + #hp-wrap .hp-tabs { margin:16px 0 0; } + #hp-wrap .hp-stats { display:flex; gap:14px; margin:16px 0; flex-wrap:wrap; } + #hp-wrap .hp-stat-card { background:#fff; border:1px solid #c3c4c7; border-radius:4px; padding:14px 22px; min-width:130px; text-align:center; } + #hp-wrap .hp-stat-num { font-size:2em; font-weight:700; color:#2271b1; line-height:1.2; } + #hp-wrap .hp-stat-lbl { color:#646970; font-size:12px; } + #hp-wrap .hp-filters { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; } + #hp-wrap .hp-filters input, #hp-wrap .hp-filters select { height:32px; } + #hp-wrap table.hp-log { width:100%; border-collapse:collapse; background:#fff; } + #hp-wrap table.hp-log th { background:#f0f0f1; padding:8px 12px; text-align:left; border-bottom:2px solid #c3c4c7; white-space:nowrap; } + #hp-wrap table.hp-log td { padding:8px 12px; border-bottom:1px solid #f0f0f1; vertical-align:top; } + #hp-wrap table.hp-log tr:hover td { background:#f6f7f7; } + #hp-wrap .hp-ua { font-size:11px; color:#646970; max-width:300px; word-break:break-all; } + #hp-wrap .hp-badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:11px; font-weight:600; background:#ffecec; color:#b32d2e; border:1px solid #f7c5c5; } + #hp-wrap .hp-pager { margin:12px 0; display:flex; align-items:center; gap:8px; } + #hp-wrap .hp-pager a, #hp-wrap .hp-pager span { display:inline-block; padding:4px 10px; border:1px solid #c3c4c7; border-radius:3px; background:#fff; text-decoration:none; color:#2271b1; } + #hp-wrap .hp-pager span.current { background:#2271b1; color:#fff; border-color:#2271b1; } + #hp-wrap .hp-red { color:#b32d2e; } + #hp-wrap .hp-api-status { display:inline-flex; align-items:center; gap:6px; font-weight:600; } + #hp-wrap .hp-api-status .dot { width:10px; height:10px; border-radius:50%; display:inline-block; } + #hp-wrap .dot-on { background:#00a32a; } + #hp-wrap .dot-off { background:#646970; } + '); } public static function handle_actions() { @@ -236,9 +297,29 @@ class SmartHoneypotAdmin { if (!current_user_can('manage_options')) { wp_die('Unauthorized'); } + if ($_POST['hp_action'] === 'clear_logs') { SmartHoneypotDB::clear(); - wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'cleared' => 1], admin_url('admin.php'))); + wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'tab' => 'logs', 'cleared' => 1], admin_url('admin.php'))); + exit; + } + + if ($_POST['hp_action'] === 'save_api_settings') { + $current = SmartHoneypotAPIClient::settings(); + $new = [ + 'enabled' => !empty($_POST['hp_api_enabled']), + 'api_url' => esc_url_raw(trim($_POST['hp_api_url'] ?? '')), + 'last_sync' => $current['last_sync'], + 'sent_total' => $current['sent_total'], + ]; + update_option(SmartHoneypotAPIClient::OPT_SETTINGS, $new); + wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'tab' => 'settings', 'saved' => 1], admin_url('admin.php'))); + exit; + } + + if ($_POST['hp_action'] === 'flush_queue') { + SmartHoneypotAPIClient::flush(); + wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'tab' => 'settings', 'flushed' => 1], admin_url('admin.php'))); exit; } } @@ -248,165 +329,217 @@ class SmartHoneypotAdmin { return; } - // Filters from query string - $search = sanitize_text_field($_GET['hp_search'] ?? ''); - $filter_ip = sanitize_text_field($_GET['hp_ip'] ?? ''); - $filter_form = sanitize_text_field($_GET['hp_form'] ?? ''); - $paged = max(1, intval($_GET['paged'] ?? 1)); - $per_page = self::PER_PAGE; - $offset = ($paged - 1) * $per_page; - - $query_args = array_filter([ - 'ip' => $filter_ip, - 'form' => $filter_form, - 'search' => $search, - 'per_page' => $per_page, - 'offset' => $offset, - ]); - - $rows = SmartHoneypotDB::get_rows($query_args); - $total = SmartHoneypotDB::count($query_args); - $total_ever = SmartHoneypotDB::count(); - $form_types = SmartHoneypotDB::get_form_types(); - $total_pages = max(1, ceil($total / $per_page)); - - // Unique IPs total - global $wpdb; - $unique_ips = (int) $wpdb->get_var("SELECT COUNT(DISTINCT ip_address) FROM " . SmartHoneypotDB::table()); - $today = (int) $wpdb->get_var( - "SELECT COUNT(*) FROM " . SmartHoneypotDB::table() . " WHERE blocked_at >= CURDATE()" - ); - - $base_url = admin_url('admin.php?page=' . self::MENU_SLUG); + $tab = sanitize_key($_GET['tab'] ?? 'logs'); ?> -
        -

        Honeypot Logs

        +
        +

        Honeypot Fields

        -

        All logs have been cleared.

        +

        Logs cleared.

        + + +

        API settings saved.

        + + +

        Queue flushed to central API.

        - -
        -
        -
        -
        Total Blocked
        -
        -
        -
        -
        Blocked Today
        -
        -
        -
        -
        Unique IPs
        -
        -
        -
        -
        Form Types Hit
        -
        -
        + - -
        - -
        - - - - - - Reset - - - - - - - - -
        + + + + + + +
        + $filter_ip, 'form' => $filter_form, + 'search' => $search, 'per_page' => $per_page, 'offset' => $offset, + ]); + + $rows = SmartHoneypotDB::get_rows($qargs); + $total = SmartHoneypotDB::count($qargs); + $total_ever = SmartHoneypotDB::count(); + $form_types = SmartHoneypotDB::get_form_types(); + $total_pages = max(1, ceil($total / $per_page)); + + global $wpdb; + $unique_ips = (int) $wpdb->get_var("SELECT COUNT(DISTINCT ip_address) FROM " . SmartHoneypotDB::table()); + $today = (int) $wpdb->get_var("SELECT COUNT(*) FROM " . SmartHoneypotDB::table() . " WHERE blocked_at >= CURDATE()"); + + $base = admin_url('admin.php?page=' . self::MENU_SLUG . '&tab=logs'); + ?> + +
        +
        Total Blocked
        +
        Today
        +
        Unique IPs
        +
        Form Types Hit
        +
        + + +
        + + +
        + + + + + + Reset + + + + + + + +
        + + +

        Showing result (page of )

        + + + + + + + + + + + + + + + + + + + + +
        #Date / TimeIP AddressForm TypeReasonURIUser Agent
        No blocked attempts recorded yet.
        id) ?>blocked_at) ?> + ip_address) ?>
        + filter +  lookup ↗ +
        form_type) ?>reason) ?>request_uri) ?>user_agent) ?>
        + + 1): ?> +
        + 1): ?> + ← Prev + + $p]))); ?> + + + + + + + + Next → + +
        + + +
        +

        Central API Settings

        +

        + Submit blocked attempts anonymously to a central dashboard for aggregate threat intelligence. + Only anonymised data is sent: masked IPs (first 2 octets only), form type, block reason, and UA family. No site URL, no full IPs. +

        + +
        + + + + + + + + + + + + +
        Enable Submission + +
        API Endpoint URL + +

        Base URL of your Honeypot API Docker container.

        +
        + +
        -

        Showing result - (page of )

        +
        - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +

        Submission Status

        +
        #Date / TimeIP AddressForm TypeReasonURIUser Agent
        - No blocked attempts recorded yet. -
        id) ?>blocked_at) ?> - ip_address) ?>
        - filter -   - lookup ↗ -
        form_type) ?>reason) ?>request_uri) ?>user_agent) ?>
        + + + + + + + +
        Status + + + + +
        Last Sync
        Total Sent blocks
        Queue Size pending blocks
        Next Auto-Flush
        - - 1): ?> -
        - 1): ?> - ← Prev - - - $p]))); - if ($p === $paged): ?> - - - - - - - Next → - -
        + 0 && $s['enabled'] && $s['api_url']): ?> +
        + + + +
        - -
        +
        @@ -496,7 +617,7 @@ class SmartHoneypotAntiSpam { esc_attr($this->hp_name), esc_attr($this->token_name), esc_attr($this->time_name), - esc_attr($ts) + esc_attr(time()) ); } @@ -504,9 +625,7 @@ class SmartHoneypotAntiSpam { echo $this->get_honeypot_html(); } - /* ------------------------------------------------------------------ - * INJECTION HELPERS - * ----------------------------------------------------------------*/ + /* ── Injection helpers ─────────────────────────────────────────── */ public function add_to_content_forms($content) { if (is_admin() || is_feed()) { return $content; @@ -524,10 +643,7 @@ class SmartHoneypotAntiSpam { public function add_to_elementor_form($field, $instance) { static $done = false; - if (!$done && $field['type'] === 'submit') { - $done = true; - echo $this->get_honeypot_html(); - } + if (!$done && $field['type'] === 'submit') { $done = true; echo $this->get_honeypot_html(); } } public function filter_elementor_widget($content, $widget) { @@ -545,9 +661,7 @@ class SmartHoneypotAntiSpam { return preg_replace('/(\[submit[^\]]*\])/i', $this->get_honeypot_html() . '$1', $form, 1); } - /* ------------------------------------------------------------------ - * VALIDATION - * ----------------------------------------------------------------*/ + /* ── Validation ────────────────────────────────────────────────── */ private function check_submission(bool $require_fields = true): bool { if ($require_fields && !isset($_POST[$this->hp_name])) { $this->log_spam('Honeypot field missing (direct POST)'); @@ -571,14 +685,8 @@ class SmartHoneypotAntiSpam { } if (isset($_POST[$this->time_name])) { $diff = time() - intval($_POST[$this->time_name]); - if ($diff < self::MIN_SUBMIT_TIME) { - $this->log_spam("Submitted too fast ({$diff}s — bot behaviour)"); - return false; - } - if ($diff > self::MAX_SUBMIT_TIME) { - $this->log_spam("Timestamp expired ({$diff}s old)"); - return false; - } + if ($diff < self::MIN_SUBMIT_TIME) { $this->log_spam("Submitted too fast ({$diff}s)"); return false; } + if ($diff > self::MAX_SUBMIT_TIME) { $this->log_spam("Timestamp expired ({$diff}s)"); return false; } } $this->clean_post_data(); return true; @@ -604,7 +712,7 @@ class SmartHoneypotAntiSpam { $uri = $_SERVER['REQUEST_URI'] ?? ''; $ua = $_SERVER['HTTP_USER_AGENT'] ?? ''; - // Write to DB + // Write to local DB SmartHoneypotDB::insert([ 'ip' => $ip, 'form' => $this->current_form_type, @@ -613,13 +721,19 @@ class SmartHoneypotAntiSpam { 'ua' => $ua, ]); - // Also write to PHP error log for server-level monitoring + // Queue for central API + SmartHoneypotAPIClient::enqueue([ + 'ip' => $ip, + 'form_type' => $this->current_form_type, + 'reason' => $reason, + 'user_agent' => $ua, + 'blocked_at' => current_time('mysql'), + ]); + error_log("[Honeypot] {$reason} | form={$this->current_form_type} | ip={$ip} | uri={$uri}"); } - /* ------------------------------------------------------------------ - * RATE LIMITING - * ----------------------------------------------------------------*/ + /* ── Rate limiting ─────────────────────────────────────────────── */ private function check_rate_limit(): bool { $ip = $_SERVER['REMOTE_ADDR'] ?? ''; if (!$ip) { @@ -628,17 +742,14 @@ class SmartHoneypotAntiSpam { $key = 'hp_rate_' . md5($ip); $count = (int) get_transient($key); if ($count >= self::RATE_LIMIT) { - $this->current_form_type .= ' (rate limited)'; - $this->log_spam("Rate limit hit — {$count} attempts this hour from {$ip}"); + $this->log_spam("Rate limit exceeded ({$count}/hr from {$ip})"); return false; } set_transient($key, $count + 1, HOUR_IN_SECONDS); return true; } - /* ------------------------------------------------------------------ - * WOOCOMMERCE - * ----------------------------------------------------------------*/ + /* ── WooCommerce ───────────────────────────────────────────────── */ public function validate_wc_registration($errors, $username, $password, $email) { $this->current_form_type = 'WooCommerce Registration'; if (!$this->check_submission(true)) { @@ -646,7 +757,7 @@ class SmartHoneypotAntiSpam { return $errors; } if (!$this->check_rate_limit()) { - $errors->add('honeypot_rate', __('Error: Too many registration attempts. Try again later.', 'smart-honeypot')); + $errors->add('honeypot_rate', __('Error: Too many attempts. Try again later.', 'smart-honeypot')); } return $errors; } @@ -666,9 +777,7 @@ class SmartHoneypotAntiSpam { } } - /* ------------------------------------------------------------------ - * WORDPRESS CORE - * ----------------------------------------------------------------*/ + /* ── WordPress core ────────────────────────────────────────────── */ public function validate_wp_registration($errors, $login, $email) { $this->current_form_type = 'WP Registration'; if (!$this->check_submission(true)) { @@ -696,9 +805,7 @@ class SmartHoneypotAntiSpam { return $commentdata; } - /* ------------------------------------------------------------------ - * ELEMENTOR - * ----------------------------------------------------------------*/ + /* ── Elementor ─────────────────────────────────────────────────── */ public function validate_elementor_form($record, $ajax_handler) { $this->current_form_type = 'Elementor Form'; if (!$this->check_submission(true)) { @@ -706,9 +813,7 @@ class SmartHoneypotAntiSpam { } } - /* ------------------------------------------------------------------ - * GENERIC CATCH-ALL - * ----------------------------------------------------------------*/ + /* ── Generic catch-all ─────────────────────────────────────────── */ public function validate_generic_post() { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { return; @@ -716,17 +821,15 @@ class SmartHoneypotAntiSpam { if (!isset($_POST[$this->hp_name]) && !isset($_POST[$this->token_name])) { return; } - // Skip forms handled by specific hooks if ( - isset($_POST['woocommerce-register-nonce']) || - isset($_POST['woocommerce-login-nonce']) || - isset($_POST['woocommerce-process-checkout-nonce']) || - isset($_POST['comment_post_ID']) || + isset($_POST['woocommerce-register-nonce']) || + isset($_POST['woocommerce-login-nonce']) || + isset($_POST['woocommerce-process-checkout-nonce']) || + isset($_POST['comment_post_ID']) || (isset($_POST['action']) && $_POST['action'] === 'elementor_pro_forms_send_form') ) { return; } - $this->current_form_type = 'Generic Form'; if (!$this->check_submission(false)) { if (wp_doing_ajax()) { @@ -737,21 +840,16 @@ class SmartHoneypotAntiSpam { } } - /* ------------------------------------------------------------------ - * CSS - * ----------------------------------------------------------------*/ + /* ── CSS ───────────────────────────────────────────────────────── */ public function print_css() { echo ''; } - /* ------------------------------------------------------------------ - * JS — HMAC token via SubtleCrypto - * ----------------------------------------------------------------*/ + /* ── JS — HMAC token via SubtleCrypto ──────────────────────────── */ public function print_js() { $secret = esc_js($this->secret); $token_name = esc_js($this->token_name); $time_name = esc_js($this->time_name); - echo << (function(){ @@ -783,15 +881,13 @@ class SmartHoneypotAntiSpam { JSBLOCK; } - /* ------------------------------------------------------------------ - * ADMIN NOTICE - * ----------------------------------------------------------------*/ + /* ── Admin notice ──────────────────────────────────────────────── */ public function activation_notice() { if (get_transient('smart_honeypot_activated')) { - echo '
        -

        Honeypot Fields is now active. All forms are protected. View logs →

        -
        '; + echo '

        + Honeypot Fields is now active. All forms are protected. + View logs → +

        '; delete_transient('smart_honeypot_activated'); } } @@ -803,7 +899,6 @@ JSBLOCK; define('HP_PLUGIN_FILE', __FILE__); add_action('plugins_loaded', function () { - // Run DB upgrade if needed if ((int) get_option(SmartHoneypotDB::TABLE_VERSION_OPTION) < SmartHoneypotDB::TABLE_VERSION) { SmartHoneypotDB::install(); } @@ -811,19 +906,29 @@ add_action('plugins_loaded', function () { SmartHoneypotAdmin::register(); }); +// Custom cron interval (5 minutes) +add_filter('cron_schedules', function ($s) { + if (!isset($s['hp_5min'])) { + $s['hp_5min'] = ['interval' => 300, 'display' => 'Every 5 Minutes']; + } + return $s; +}); + +// Cron hooks +add_action('hp_api_flush', ['SmartHoneypotAPIClient', 'flush']); +add_action('hp_daily_cleanup', function () { + SmartHoneypotDB::delete_older_than_days(90); +}); + register_activation_hook(__FILE__, function () { SmartHoneypotDB::install(); set_transient('smart_honeypot_activated', true, 30); + if (!wp_next_scheduled('hp_api_flush')) wp_schedule_event(time(), 'hp_5min', 'hp_api_flush'); + if (!wp_next_scheduled('hp_daily_cleanup')) wp_schedule_event(time(), 'daily', 'hp_daily_cleanup'); }); register_deactivation_hook(__FILE__, function () { delete_transient('smart_honeypot_activated'); + wp_clear_scheduled_hook('hp_api_flush'); + wp_clear_scheduled_hook('hp_daily_cleanup'); }); - -// Auto-prune logs older than 90 days (runs once daily) -add_action('hp_daily_cleanup', function () { - SmartHoneypotDB::delete_older_than_days(90); -}); -if (!wp_next_scheduled('hp_daily_cleanup')) { - wp_schedule_event(time(), 'daily', 'hp_daily_cleanup'); -}