486 lines
16 KiB
Markdown
486 lines
16 KiB
Markdown
# portspoof_concentrator
|
|
|
|
A PHP/MySQL web application that aggregates connection data from one or more [portspoof_py](../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](#requirements)
|
|
2. [File structure](#file-structure)
|
|
3. [Installation](#installation)
|
|
4. [Configuration](#configuration)
|
|
5. [Database schema](#database-schema)
|
|
6. [Web interface authentication](#web-interface-authentication)
|
|
7. [Adding nodes](#adding-nodes)
|
|
8. [Fetch cron](#fetch-cron)
|
|
9. [HTTP trigger endpoint](#http-trigger-endpoint)
|
|
10. [Dashboard](#dashboard)
|
|
11. [Upgrading](#upgrading)
|
|
12. [Troubleshooting](#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
|
|
|
|
```bash
|
|
git clone <repo> /var/www/portspoof_concentrator
|
|
# or copy the directory to your web root
|
|
```
|
|
|
|
### 2. Create a MySQL user and database
|
|
|
|
```sql
|
|
CREATE USER 'portspoof'@'localhost' IDENTIFIED BY 'strongpassword';
|
|
GRANT ALL PRIVILEGES ON portspoof_concentrator.* TO 'portspoof'@'localhost';
|
|
FLUSH PRIVILEGES;
|
|
```
|
|
|
|
### 3. Edit `config.php`
|
|
|
|
```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](#web-interface-authentication) for details):
|
|
|
|
```bash
|
|
php -r "echo password_hash('yourpassword', PASSWORD_DEFAULT) . PHP_EOL;"
|
|
```
|
|
|
|
Paste the output into `config.php`:
|
|
|
|
```php
|
|
define('UI_USER', 'admin');
|
|
define('UI_PASS_HASH', '$2y$12$...');
|
|
```
|
|
|
|
See [Configuration](#configuration) for the full list of constants.
|
|
|
|
### 4. Run the setup script
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```apache
|
|
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**:
|
|
|
|
```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`:
|
|
|
|
```bash
|
|
# 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:
|
|
|
|
```bash
|
|
php -r "echo password_hash('yourpassword', PASSWORD_DEFAULT) . PHP_EOL;"
|
|
```
|
|
|
|
Add it to `config.php`:
|
|
|
|
```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`):
|
|
|
|
```bash
|
|
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_HASH` in `config.php`.
|
|
- After the first web-interface password change: `auth.passwd` takes over and `UI_PASS_HASH` is ignored.
|
|
|
|
Add `auth.passwd` to your `.gitignore` to avoid committing credentials:
|
|
|
|
```
|
|
auth.passwd
|
|
```
|
|
|
|
### Disabling authentication
|
|
|
|
Set `UI_PASS_HASH` to an empty string:
|
|
|
|
```php
|
|
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
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
php -r "echo bin2hex(random_bytes(32)) . PHP_EOL;"
|
|
```
|
|
|
|
Set it in `config.php`:
|
|
|
|
```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:**
|
|
```bash
|
|
curl https://yourserver/trigger.php?token=your-token
|
|
```
|
|
|
|
**Authorization header (preferred — keeps the token out of server logs):**
|
|
```bash
|
|
curl -H "Authorization: Bearer your-token" https://yourserver/trigger.php
|
|
```
|
|
|
|
Both `GET` and `POST` are accepted.
|
|
|
|
### Response
|
|
|
|
On success (`200 OK`):
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```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:
|
|
|
|
```bash
|
|
php --version
|
|
```
|