16 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
- Requirements
- File structure
- Installation
- Configuration
- Database schema
- Web interface authentication
- Adding nodes
- Fetch cron
- HTTP trigger endpoint
- Dashboard
- Upgrading
- 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
├── login.php Login form
├── logout.php Session teardown
├── settings.php Change password via the web interface
├── trigger.php HTTP endpoint to trigger a fetch run (token-protected)
├── version.php Application version constant (bump on each release)
├── auth.passwd Live password hash (auto-created by settings.php, gitignore this)
├── includes/
│ ├── auth.php Session management, login helpers, save_password()
│ ├── db.php PDO singleton
│ ├── footer.php Shared footer with version number
│ ├── 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
Set a UI password (see Web interface authentication for details):
php -r "echo password_hash('yourpassword', PASSWORD_DEFAULT) . PHP_EOL;"
Paste the output into config.php:
define('UI_USER', 'admin');
define('UI_PASS_HASH', '$2y$12$...');
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 |
UI_USER |
'admin' |
Username for the web interface |
UI_PASS_HASH |
'' |
Bcrypt hash of the UI password. Empty string disables authentication |
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.
Web interface authentication
The dashboard and node management pages are protected by a session-based login form. Authentication is controlled by two constants in config.php.
Setup
Generate a bcrypt hash of your chosen password:
php -r "echo password_hash('yourpassword', PASSWORD_DEFAULT) . PHP_EOL;"
Add it to config.php:
define('UI_USER', 'admin'); // change to any username you like
define('UI_PASS_HASH', '$2y$12$…'); // paste the hash from the command above
Restart your web server / PHP-FPM if it caches config files. On the next visit to index.php or nodes.php you will be redirected to login.php.
Changing the password
Via the web interface (recommended): navigate to Settings in the nav bar, enter your current password and the new one, and submit. The new hash is written to auth.passwd in the project root and takes effect immediately — no server restart needed.
Via the CLI: re-run the hash command and replace the value in config.php (or write directly to auth.passwd):
php -r "echo password_hash('newpassword', PASSWORD_DEFAULT) . PHP_EOL;" > /var/www/portspoof_concentrator/auth.passwd
Existing sessions remain valid until they expire or the user signs out.
Password storage precedence
On each request, auth.php checks for auth.passwd in the project root. If the file exists its contents are used as the hash; otherwise it falls back to UI_PASS_HASH in config.php. This means:
- First-time setup: set
UI_PASS_HASHinconfig.php. - After the first web-interface password change:
auth.passwdtakes over andUI_PASS_HASHis ignored.
Add auth.passwd to your .gitignore to avoid committing credentials:
auth.passwd
Disabling authentication
Set UI_PASS_HASH to an empty string:
define('UI_PASS_HASH', '');
All pages become publicly accessible. Only do this on a private network or when another layer (firewall, VPN, web server auth) protects the interface.
Sign out
A Sign out link appears in the navigation bar on every page when authentication is enabled. Visiting logout.php directly also works.
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
-kto curl or configure certificate trust on the calling side. - Prefer the
Authorization: Bearerheader over the query string — query strings are written to web server access logs. - The
cron/fetch.phpscript checksPHP_SAPI === 'cli'and exits immediately if called over HTTP, so it cannot be triggered directly even if thecron/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-porton the target host. - Check the API URL in
nodes.php— no trailing slash, correct scheme (httpvshttps). - 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_atcursor 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 latestFETCH_LIMITevents.
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.phpmatch 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