Files
portspoof_concentrator/README.md
2026-03-11 10:14:26 -04:00

13 KiB

portspoof_concentrator

A PHP/MySQL web application that aggregates connection data from one or more portspoof_py honeypot nodes into a single dashboard.

Each portspoof_py instance runs independently and exposes a JSON API. portspoof_concentrator polls that API on a schedule, stores the events in a central MySQL database, and presents them in a unified dark-themed dashboard with per-node filtering, top-IP and top-port charts, and a live connection feed.


Table of contents

  1. Requirements
  2. File structure
  3. Installation
  4. Configuration
  5. Database schema
  6. Adding nodes
  7. Fetch cron
  8. HTTP trigger endpoint
  9. Dashboard
  10. Upgrading
  11. Troubleshooting

Requirements

Requirement Notes
PHP 8.1+ Uses named return types, array|false, match, fn() arrow functions
php-curl extension For outbound HTTP calls to portspoof_py nodes
php-pdo + php-pdo_mysql Database access
MySQL 8.0+ (or MariaDB 10.5+) ADD COLUMN IF NOT EXISTS used in migrations
A running portspoof_py instance With --admin-port enabled and credentials set

File structure

portspoof_concentrator/
├── config.php            Database credentials and app-wide constants
├── schema.sql            MySQL DDL (applied by setup.php)
├── setup.php             One-time install / migration script
├── index.php             Aggregated dashboard
├── nodes.php             Add / edit / delete portspoof_py nodes
├── trigger.php           HTTP endpoint to trigger a fetch run (token-protected)
├── includes/
│   ├── db.php            PDO singleton
│   ├── functions.php     Node CRUD, fetch helpers, run_fetch(), dashboard queries
│   └── style.php         Shared CSS (included inline by both pages)
└── cron/
    └── fetch.php         CLI polling script — run via cron or manually (CLI only)

Installation

1. Clone / copy the project

git clone <repo> /var/www/portspoof_concentrator
# or copy the directory to your web root

2. Create a MySQL user and database

CREATE USER 'portspoof'@'localhost' IDENTIFIED BY 'strongpassword';
GRANT ALL PRIVILEGES ON portspoof_concentrator.* TO 'portspoof'@'localhost';
FLUSH PRIVILEGES;

3. Edit config.php

define('DB_HOST', '127.0.0.1');
define('DB_PORT', 3306);
define('DB_NAME', 'portspoof_concentrator');
define('DB_USER', 'portspoof');
define('DB_PASS', 'strongpassword');   // match the password above

See Configuration for the full list of constants.

4. Run the setup script

php /var/www/portspoof_concentrator/setup.php

Expected output:

Database 'portspoof_concentrator' and tables created/migrated successfully.

The script is safe to run on an existing database — it uses CREATE TABLE IF NOT EXISTS and idempotent ALTER TABLE migrations.

5. Configure your web server

Apache — place inside a <VirtualHost> or .htaccess:

DocumentRoot /var/www/portspoof_concentrator
DirectoryIndex index.php

<Directory /var/www/portspoof_concentrator>
    AllowOverride None
    Require all granted
</Directory>

# Deny direct access to includes and cron
<DirectoryMatch "^/var/www/portspoof_concentrator/(includes|cron)">
    Require all denied
</DirectoryMatch>

nginx:

root /var/www/portspoof_concentrator;
index index.php;

location ~ ^/(includes|cron)/ { deny all; }

location ~ \.php$ {
    fastcgi_pass unix:/run/php/php8.1-fpm.sock;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    include fastcgi_params;
}

6. Delete or protect setup.php

Once the database is initialised, restrict access to setup.php:

# Either delete it
rm /var/www/portspoof_concentrator/setup.php

# Or deny access via your web server config (see above pattern)

Configuration

All tunables are constants in config.php.

Constant Default Description
DB_HOST 127.0.0.1 MySQL host
DB_PORT 3306 MySQL port
DB_NAME portspoof_concentrator Database name
DB_USER portspoof MySQL username
DB_PASS changeme MySQL password
DASH_RECENT_LIMIT 100 Number of recent connections shown on the dashboard
RATE_WINDOW_SECONDS 60 Lookback window for the "last N seconds" connection count card
TOP_N 10 How many entries to show in the top IPs / top ports tables
FETCH_TIMEOUT 10 cURL timeout (seconds) for outbound calls to portspoof_py nodes
FETCH_LIMIT 500 Maximum connections pulled from a node per fetch run
TRIGGER_TOKEN '' Secret token for trigger.php. Empty string disables the endpoint entirely

Database schema

nodes

Stores the address and credentials of each portspoof_py instance.

Column Type Description
id INT UNSIGNED Auto-increment primary key
name VARCHAR(100) Human-readable label
api_url VARCHAR(255) Base URL of the portspoof_py admin API, e.g. https://192.168.1.10:8080
username VARCHAR(100) HTTP Basic Auth username
password VARCHAR(255) HTTP Basic Auth password (stored in plaintext — secure your DB)
verify_ssl TINYINT(1) Whether to verify the node's TLS certificate
enabled TINYINT(1) Set to 0 to pause fetching without deleting the node
last_fetched_at DATETIME Wall-clock time of the most recent successful poll
last_event_at DATETIME(6) Timestamp of the newest event ingested from this node — used as the deduplication cursor
created_at DATETIME Row creation time

connections

One row per connection event ingested from any node.

Column Type Description
id BIGINT UNSIGNED Auto-increment primary key
node_id INT UNSIGNED Foreign key → nodes.id (cascades on delete)
occurred_at DATETIME(6) Original event timestamp from portspoof_py (microsecond precision)
src_ip VARCHAR(45) Scanner source IP (supports IPv6)
src_port SMALLINT UNSIGNED Scanner ephemeral port
dst_port SMALLINT UNSIGNED Port the scanner originally targeted
banner_hex TEXT Hex-encoded banner bytes sent by portspoof_py (nullable)
banner_len INT UNSIGNED Length of the banner in bytes
created_at DATETIME Row insertion time

Indexes: (node_id, occurred_at) for cursor lookups and per-node queries; src_ip and dst_port for the top-N aggregations.


Adding nodes

Open http://yourserver/nodes.php and fill in the form.

Field Description
Name Any label, e.g. honeypot-eu-1
API URL The full base URL of the portspoof_py admin interface, e.g. https://10.0.0.5:8080 — no trailing slash
Username The username from the node's admin.passwd file
Password The password from the node's admin.passwd file
Verify SSL certificate Tick if the node uses a trusted TLS cert; leave unticked for self-signed certs
Enabled Uncheck to suspend polling without removing the node

The node's portspoof_py instance must be running with --admin-port set. The concentrator calls /api/connections?limit=500 on every poll.


Fetch cron

cron/fetch.php is the polling engine. It queries every enabled node, filters out events already seen (using nodes.last_event_at as a cursor), inserts new rows, and advances the cursor.

Run manually

php /var/www/portspoof_concentrator/cron/fetch.php

Example output:

[2026-03-10 14:05:01] node #1 (honeypot-eu-1)  fetched=87  inserted=12  last_event_at=2026-03-10 14:04:58.831204
[2026-03-10 14:05:02] node #2 (honeypot-us-1)  fetched=500  inserted=0   last_event_at=2026-03-10 14:03:41.002100

Schedule with cron

crontab -e

Add:

* * * * * /usr/bin/php /var/www/portspoof_concentrator/cron/fetch.php >> /var/log/portspoof_concentrator/fetch.log 2>&1

Create the log directory first:

mkdir -p /var/log/portspoof_concentrator
chown www-data:www-data /var/log/portspoof_concentrator   # or whichever user runs cron

How the cursor works

nodes.last_event_at stores the occurred_at timestamp of the newest event successfully ingested from a node. On the next poll, any event with occurred_at <= last_event_at is skipped. The cursor is only advanced when new rows are actually inserted, so a poll that returns no new data leaves it unchanged.

This means FETCH_LIMIT (default 500) only needs to exceed the number of connections a node receives between polls. At one-minute intervals on a busy honeypot, increase FETCH_LIMIT in config.php if you see inserted consistently equal to fetched.


HTTP trigger endpoint

trigger.php exposes the same fetch logic over HTTP, useful when you want to drive fetches from an external scheduler, a webhook, or a monitoring system rather than (or in addition to) a local cron job.

Setup

Generate a strong random token:

php -r "echo bin2hex(random_bytes(32)) . PHP_EOL;"

Set it in config.php:

define('TRIGGER_TOKEN', 'your-generated-token-here');

If TRIGGER_TOKEN is left as an empty string the endpoint returns 503 for every request.

Calling the endpoint

The token can be passed in either of two ways:

Query string:

curl https://yourserver/trigger.php?token=your-token

Authorization header (preferred — keeps the token out of server logs):

curl -H "Authorization: Bearer your-token" https://yourserver/trigger.php

Both GET and POST are accepted.

Response

On success (200 OK):

{
    "ok": true,
    "elapsed_ms": 342,
    "total_fetched": 87,
    "total_inserted": 12,
    "nodes": [
        {
            "node_id": 1,
            "name": "honeypot-eu-1",
            "fetched": 87,
            "inserted": 12,
            "last_event_at": "2026-03-10 14:04:58.831204",
            "error": null
        }
    ]
}

If at least one node fails but others succeed, the status is 207 Multi-Status and ok is false. Failed nodes include a non-null error string.

On an invalid or missing token: 401 Unauthorized. When the endpoint is disabled: 503 Service Unavailable.

Security notes

  • Use HTTPS so the token is not transmitted in plaintext. If using self-signed TLS, pass -k to curl or configure certificate trust on the calling side.
  • Prefer the Authorization: Bearer header over the query string — query strings are written to web server access logs.
  • The cron/fetch.php script checks PHP_SAPI === 'cli' and exits immediately if called over HTTP, so it cannot be triggered directly even if the cron/ directory block is misconfigured.

Dashboard

index.php auto-refreshes every 30 seconds and shows:

  • Stat cards — total connections across all nodes, connections in the last 60 seconds, number of enabled nodes, timestamp of the most recent event
  • Node filter — when more than one node is configured, click any node name to scope all tables to that node
  • Top source IPs — the 10 most active scanner IPs with a proportional bar
  • Top targeted ports — the 10 most-probed ports with a proportional bar
  • Recent connections — the 100 most recent events with timestamp, node name, source IP, source port, destination port, banner length, and a text preview of the banner
  • Node status table — all configured nodes with their last-fetched time and enabled state

Upgrading

If you deployed before last_event_at was added to the schema, run setup.php again:

php /var/www/portspoof_concentrator/setup.php

It will add the missing column and drop the legacy uq_event unique key if present. No data is lost.


Troubleshooting

ERROR: could not reach API in the fetch log

  • Confirm portspoof_py is running with --admin-port on the target host.
  • Check the API URL in nodes.php — no trailing slash, correct scheme (http vs https).
  • If using HTTPS with a self-signed certificate, make sure Verify SSL is unchecked for that node.
  • Test manually: curl -su username:password http://host:port/api/stats

inserted is always 0

  • The node's last_event_at cursor may be ahead of the events being returned. This is normal if the node has no new connections since the last poll.
  • If you suspect the cursor is wrong, reset it: UPDATE nodes SET last_event_at = NULL WHERE id = <id>; — the next poll will re-ingest the latest FETCH_LIMIT events.

Dashboard shows no data

  • Check that at least one node is configured and enabled in nodes.php.
  • Run the fetch script manually and check its output for errors.
  • Verify the DB credentials in config.php match the MySQL user you created.

FETCH_LIMIT warning — inserted equals fetched every run

The node is generating more connections between polls than FETCH_LIMIT allows. Increase it in config.php:

define('FETCH_LIMIT', 2000);

or run the cron more frequently (e.g. every 30 seconds using two crontab entries offset by 30 seconds).

PHP fatal errors about return type syntax

PHP 8.1 or later is required. Check your version:

php --version