First push

This commit is contained in:
2026-03-11 10:14:26 -04:00
parent eaf4dbbc3b
commit 20ed0eeadb
11 changed files with 1333 additions and 1 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
#Password files
admin.passwd
email.json
admin.crt
admin.key
config.php

399
README.md
View File

@@ -1,3 +1,400 @@
# portspoof_concentrator
php/mysql server to gather data from portspoof_py nodes
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. [Adding nodes](#adding-nodes)
7. [Fetch cron](#fetch-cron)
8. [HTTP trigger endpoint](#http-trigger-endpoint)
9. [Dashboard](#dashboard)
10. [Upgrading](#upgrading)
11. [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
├── 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
```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
```
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 |
---
## 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
```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
```

36
cron/fetch.php Executable file
View File

@@ -0,0 +1,36 @@
#!/usr/bin/env php
<?php
/**
* portspoof_concentrator fetch cron
*
* Polls every enabled portspoof_py node's JSON API and ingests new connections.
* Must be run from the command line — exits immediately if called over HTTP.
*
* Recommended crontab entry (every minute):
* * * * * * /usr/bin/php /path/to/portspoof_concentrator/cron/fetch.php >> /var/log/portspoof_concentrator/fetch.log 2>&1
*/
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit;
}
require_once __DIR__ . '/../includes/functions.php';
$nodes = get_all_nodes();
$enabled = array_filter($nodes, fn($n) => (bool)$n['enabled']);
if (empty($enabled)) {
echo date('[Y-m-d H:i:s]') . " No enabled nodes configured.\n";
exit(0);
}
foreach (run_fetch() as $r) {
$label = sprintf('[%s] node #%d (%s)', date('Y-m-d H:i:s'), $r['node_id'], $r['name']);
if ($r['error']) {
echo "$label ERROR: {$r['error']}\n";
} else {
echo "$label fetched={$r['fetched']} inserted={$r['inserted']}"
. " last_event_at=" . ($r['last_event_at'] ?? 'none') . "\n";
}
}

18
includes/db.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
require_once __DIR__ . '/../config.php';
function db(): PDO {
static $pdo = null;
if ($pdo === null) {
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
DB_HOST, DB_PORT, DB_NAME
);
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
return $pdo;
}

272
includes/functions.php Normal file
View File

@@ -0,0 +1,272 @@
<?php
require_once __DIR__ . '/db.php';
// ── Node helpers ──────────────────────────────────────────────────────────────
function get_all_nodes(): array {
return db()->query('SELECT * FROM nodes ORDER BY name')->fetchAll();
}
function get_node(int $id): array|false {
$s = db()->prepare('SELECT * FROM nodes WHERE id = ?');
$s->execute([$id]);
return $s->fetch();
}
function upsert_node(array $data, ?int $id = null): int {
$pdo = db();
if ($id) {
$s = $pdo->prepare(
'UPDATE nodes SET name=?, api_url=?, username=?, password=?,
verify_ssl=?, enabled=? WHERE id=?'
);
$s->execute([
$data['name'], $data['api_url'], $data['username'], $data['password'],
(int)($data['verify_ssl'] ?? 0), (int)($data['enabled'] ?? 1), $id,
]);
return $id;
}
$s = $pdo->prepare(
'INSERT INTO nodes (name, api_url, username, password, verify_ssl, enabled)
VALUES (?, ?, ?, ?, ?, ?)'
);
$s->execute([
$data['name'], $data['api_url'], $data['username'], $data['password'],
(int)($data['verify_ssl'] ?? 0), (int)($data['enabled'] ?? 1),
]);
return (int)$pdo->lastInsertId();
}
function delete_node(int $id): void {
$s = db()->prepare('DELETE FROM nodes WHERE id = ?');
$s->execute([$id]);
}
// ── Fetch helpers ─────────────────────────────────────────────────────────────
/**
* Call the portspoof_py JSON API on a node.
* Returns decoded JSON on success, or false on failure.
*/
function fetch_node_api(array $node, string $path): mixed {
$url = rtrim($node['api_url'], '/') . $path;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => FETCH_TIMEOUT,
CURLOPT_USERPWD => $node['username'] . ':' . $node['password'],
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
CURLOPT_SSL_VERIFYPEER => (bool)$node['verify_ssl'],
CURLOPT_SSL_VERIFYHOST => $node['verify_ssl'] ? 2 : 0,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $code !== 200) {
return false;
}
return json_decode($body, true);
}
/**
* Ingest connection events from portspoof_py that are strictly newer than
* $last_event_at (ISO 8601 string, or null to ingest everything).
*
* Returns ['inserted' => int, 'new_max_ts' => string|null]
* where new_max_ts is the DATETIME(6) of the newest row inserted, or null if
* nothing new was inserted.
*/
function ingest_connections(int $node_id, array $events, ?string $last_event_at): array {
$inserted = 0;
$new_max_ts = null;
$s = db()->prepare(
'INSERT INTO connections
(node_id, occurred_at, src_ip, src_port, dst_port, banner_hex, banner_len)
VALUES (?, ?, ?, ?, ?, ?, ?)'
);
foreach ($events as $ev) {
$raw_ts = $ev['timestamp'] ?? null;
if ($raw_ts === null) {
continue;
}
// Normalise to MySQL DATETIME(6)
$ts = date('Y-m-d H:i:s.u', strtotime($raw_ts));
// Skip events already ingested
if ($last_event_at !== null && $ts <= $last_event_at) {
continue;
}
$s->execute([
$node_id,
$ts,
$ev['src_ip'] ?? '',
(int)($ev['src_port'] ?? 0),
(int)($ev['dst_port'] ?? 0),
$ev['banner_hex'] ?? null,
(int)($ev['banner_len'] ?? 0),
]);
$inserted++;
if ($new_max_ts === null || $ts > $new_max_ts) {
$new_max_ts = $ts;
}
}
return ['inserted' => $inserted, 'new_max_ts' => $new_max_ts];
}
/**
* Poll every enabled node, ingest new events, and advance each node's cursor.
*
* Returns an array of per-node result maps:
* ['node_id', 'name', 'fetched', 'inserted', 'last_event_at', 'error']
*/
function run_fetch(): array {
$nodes = get_all_nodes();
$enabled = array_filter($nodes, fn($n) => (bool)$n['enabled']);
$results = [];
foreach ($enabled as $node) {
$entry = [
'node_id' => (int)$node['id'],
'name' => $node['name'],
'fetched' => 0,
'inserted' => 0,
'last_event_at' => $node['last_event_at'],
'error' => null,
];
$events = fetch_node_api($node, '/api/connections?limit=' . FETCH_LIMIT);
if ($events === false) {
$entry['error'] = 'could not reach API';
$results[] = $entry;
continue;
}
if (!is_array($events)) {
$entry['error'] = 'unexpected API response';
$results[] = $entry;
continue;
}
$entry['fetched'] = count($events);
$result = ingest_connections((int)$node['id'], $events, $node['last_event_at']);
$entry['inserted'] = $result['inserted'];
$s = db()->prepare(
'UPDATE nodes SET last_fetched_at = NOW()'
. ($result['new_max_ts'] !== null ? ', last_event_at = ?' : '')
. ' WHERE id = ?'
);
$params = $result['new_max_ts'] !== null
? [$result['new_max_ts'], $node['id']]
: [$node['id']];
$s->execute($params);
if ($result['new_max_ts'] !== null) {
$entry['last_event_at'] = $result['new_max_ts'];
}
$results[] = $entry;
}
return $results;
}
// ── Dashboard stats ───────────────────────────────────────────────────────────
function global_stats(): array {
$pdo = db();
$total = (int)$pdo->query('SELECT COUNT(*) FROM connections')->fetchColumn();
$since = date('Y-m-d H:i:s', time() - RATE_WINDOW_SECONDS);
$recent = (int)$pdo->prepare('SELECT COUNT(*) FROM connections WHERE occurred_at >= ?')
->execute([$since]) ? : 0;
$s = $pdo->prepare('SELECT COUNT(*) FROM connections WHERE occurred_at >= ?');
$s->execute([$since]);
$recent = (int)$s->fetchColumn();
$s = $pdo->query('SELECT MAX(occurred_at) FROM connections');
$last = $s->fetchColumn() ?: null;
return compact('total', 'recent', 'last');
}
function top_ips(int $n = TOP_N): array {
$s = db()->prepare(
'SELECT src_ip, COUNT(*) AS cnt
FROM connections
GROUP BY src_ip
ORDER BY cnt DESC
LIMIT ?'
);
$s->execute([$n]);
return $s->fetchAll();
}
function top_ports(int $n = TOP_N): array {
$s = db()->prepare(
'SELECT dst_port, COUNT(*) AS cnt
FROM connections
GROUP BY dst_port
ORDER BY cnt DESC
LIMIT ?'
);
$s->execute([$n]);
return $s->fetchAll();
}
function top_ips_by_node(int $node_id, int $n = TOP_N): array {
$s = db()->prepare(
'SELECT src_ip, COUNT(*) AS cnt
FROM connections
WHERE node_id = ?
GROUP BY src_ip
ORDER BY cnt DESC
LIMIT ?'
);
$s->execute([$node_id, $n]);
return $s->fetchAll();
}
function recent_connections(int $limit = DASH_RECENT_LIMIT, ?int $node_id = null): array {
if ($node_id !== null) {
$s = db()->prepare(
'SELECT c.*, n.name AS node_name
FROM connections c JOIN nodes n ON n.id = c.node_id
WHERE c.node_id = ?
ORDER BY c.occurred_at DESC
LIMIT ?'
);
$s->execute([$node_id, $limit]);
} else {
$s = db()->prepare(
'SELECT c.*, n.name AS node_name
FROM connections c JOIN nodes n ON n.id = c.node_id
ORDER BY c.occurred_at DESC
LIMIT ?'
);
$s->execute([$limit]);
}
return $s->fetchAll();
}
// ── Misc ──────────────────────────────────────────────────────────────────────
function h(string $s): string {
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function banner_text(string $hex): string {
$raw = hex2bin($hex);
if ($raw === false) return '';
return mb_convert_encoding($raw, 'UTF-8', 'latin-1');
}

88
includes/style.php Normal file
View File

@@ -0,0 +1,88 @@
<?php // Inline CSS included by both index.php and nodes.php ?>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d1117;
--surface: #161b22;
--border: #30363d;
--text: #c9d1d9;
--muted: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--red: #f85149;
--yellow: #d29922;
}
body { background: var(--bg); color: var(--text); font: 14px/1.6 'Segoe UI', system-ui, sans-serif; }
header {
display: flex; align-items: center; gap: 1.5rem;
padding: .75rem 1.5rem;
background: var(--surface); border-bottom: 1px solid var(--border);
}
header h1 { font-size: 1.1rem; font-weight: 600; color: #e6edf3; }
header h1 span { color: var(--accent); }
header nav { display: flex; gap: 1rem; margin-left: auto; }
header nav a { color: var(--muted); text-decoration: none; padding: .25rem .5rem; border-radius: 4px; }
header nav a:hover, header nav a.active { color: var(--accent); background: rgba(88,166,255,.1); }
main { max-width: 1200px; margin: 1.5rem auto; padding: 0 1rem; display: flex; flex-direction: column; gap: 1.25rem; }
.stats-row { display: flex; gap: 1rem; flex-wrap: wrap; }
.stat-card {
flex: 1 1 150px;
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
padding: 1rem 1.25rem;
}
.stat-card .label { font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
.stat-card .value { font-size: 1.75rem; font-weight: 700; color: #e6edf3; margin-top: .15rem; }
.card {
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
padding: 1.25rem;
}
.card h2 { font-size: .9rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); margin-bottom: 1rem; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 700px) { .two-col { grid-template-columns: 1fr; } }
table { width: 100%; border-collapse: collapse; font-size: .85rem; }
th { text-align: left; color: var(--muted); font-weight: 500; padding: .4rem .6rem; border-bottom: 1px solid var(--border); }
td { padding: .4rem .6rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255,255,255,.03); }
code { font-family: 'Cascadia Code', 'Fira Mono', monospace; font-size: .8rem; color: var(--accent); }
label { display: block; margin-bottom: .75rem; font-size: .85rem; color: var(--muted); }
label.inline { display: flex; align-items: center; gap: .5rem; color: var(--text); }
label input[type=text], label input[type=url], label input[type=password] {
display: block; width: 100%; margin-top: .3rem;
background: var(--bg); border: 1px solid var(--border); border-radius: 5px;
color: var(--text); padding: .45rem .6rem; font-size: .9rem;
}
label input:focus { outline: none; border-color: var(--accent); }
button[type=submit], .btn {
background: var(--accent); color: #0d1117; border: none; border-radius: 5px;
padding: .5rem 1.1rem; font-size: .9rem; font-weight: 600; cursor: pointer;
}
button[type=submit]:hover { opacity: .85; }
.link-btn { background: none; border: none; cursor: pointer; font-size: .85rem; color: var(--accent); padding: 0; }
.link-btn.danger { color: var(--red); }
.alert { padding: .65rem 1rem; border-radius: 6px; font-size: .875rem; }
.alert.ok { background: rgba(63,185,80,.15); color: var(--green); border: 1px solid rgba(63,185,80,.4); }
.alert.err { background: rgba(248,81,73,.15); color: var(--red); border: 1px solid rgba(248,81,73,.4); }
.badge { display: inline-block; font-size: .7rem; padding: .1rem .45rem; border-radius: 3px; background: var(--border); color: var(--muted); }
.badge.ok { background: rgba(63,185,80,.2); color: var(--green); }
.muted { color: var(--muted); }
.actions { white-space: nowrap; }
.actions a, .actions .link-btn { margin-right: .5rem; }
.bar-wrap { display: flex; align-items: center; gap: .5rem; }
.bar { height: 6px; background: var(--accent); border-radius: 3px; min-width: 2px; }
form { max-width: 480px; }

199
index.php Normal file
View File

@@ -0,0 +1,199 @@
<?php
require_once __DIR__ . '/includes/functions.php';
$nodes = get_all_nodes();
$stats = global_stats();
$t_ips = top_ips();
$t_ports = top_ports();
$recent = recent_connections(DASH_RECENT_LIMIT);
$node_count = count($nodes);
$enabled_count = count(array_filter($nodes, fn($n) => (bool)$n['enabled']));
// Optional per-node filter
$filter_node = isset($_GET['node']) ? (int)$_GET['node'] : null;
if ($filter_node) {
$t_ips = top_ips_by_node($filter_node);
$recent = recent_connections(DASH_RECENT_LIMIT, $filter_node);
}
$max_ip_cnt = $t_ips ? max(array_column($t_ips, 'cnt')) : 1;
$max_port_cnt = $t_ports ? max(array_column($t_ports, 'cnt')) : 1;
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="30">
<title>Dashboard portspoof concentrator</title>
<style>
<?php include __DIR__ . '/includes/style.php'; ?>
</style>
</head>
<body>
<header>
<h1>portspoof<span>concentrator</span></h1>
<nav>
<a href="index.php" class="active">Dashboard</a>
<a href="nodes.php">Nodes</a>
</nav>
</header>
<main>
<!-- Stat cards -->
<div class="stats-row">
<div class="stat-card">
<div class="label">Total connections</div>
<div class="value"><?= number_format($stats['total']) ?></div>
</div>
<div class="stat-card">
<div class="label">Last <?= RATE_WINDOW_SECONDS ?>s</div>
<div class="value"><?= number_format($stats['recent']) ?></div>
</div>
<div class="stat-card">
<div class="label">Nodes</div>
<div class="value"><?= $enabled_count ?> <span class="muted" style="font-size:1rem">/ <?= $node_count ?></span></div>
</div>
<div class="stat-card">
<div class="label">Last connection</div>
<div class="value" style="font-size:1rem"><?= $stats['last'] ? h($stats['last']) : '—' ?></div>
</div>
</div>
<!-- Node filter -->
<?php if (count($nodes) > 1): ?>
<div class="card" style="padding:.75rem 1.25rem">
<span class="muted" style="font-size:.8rem">Filter by node:</span>
&nbsp;<a href="index.php" <?= !$filter_node ? 'style="color:var(--accent)"' : '' ?>>All</a>
<?php foreach ($nodes as $n): ?>
&nbsp;<a href="index.php?node=<?= (int)$n['id'] ?>"
<?= $filter_node === (int)$n['id'] ? 'style="color:var(--accent)"' : '' ?>>
<?= h($n['name']) ?>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Top IPs + Top Ports -->
<div class="two-col">
<div class="card">
<h2>Top source IPs</h2>
<?php if (empty($t_ips)): ?>
<p class="muted">No data yet.</p>
<?php else: ?>
<table>
<thead><tr><th>IP</th><th>Connections</th><th></th></tr></thead>
<tbody>
<?php foreach ($t_ips as $row): ?>
<tr>
<td><code><?= h($row['src_ip']) ?></code></td>
<td><?= number_format($row['cnt']) ?></td>
<td style="width:40%">
<div class="bar-wrap">
<div class="bar" style="width:<?= round($row['cnt'] / $max_ip_cnt * 100) ?>%"></div>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<div class="card">
<h2>Top targeted ports</h2>
<?php if (empty($t_ports)): ?>
<p class="muted">No data yet.</p>
<?php else: ?>
<table>
<thead><tr><th>Port</th><th>Connections</th><th></th></tr></thead>
<tbody>
<?php foreach ($t_ports as $row): ?>
<tr>
<td><code><?= (int)$row['dst_port'] ?></code></td>
<td><?= number_format($row['cnt']) ?></td>
<td style="width:40%">
<div class="bar-wrap">
<div class="bar" style="width:<?= round($row['cnt'] / $max_port_cnt * 100) ?>%"></div>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
<!-- Recent connections -->
<div class="card">
<h2>Recent connections <?= $filter_node ? '(filtered)' : '' ?></h2>
<?php if (empty($recent)): ?>
<p class="muted">No connections ingested yet. Make sure at least one node is configured and the fetch cron is running.</p>
<?php else: ?>
<table>
<thead>
<tr>
<th>Time (UTC)</th>
<th>Node</th>
<th>Source IP</th>
<th>Src port</th>
<th>Dst port</th>
<th>Banner len</th>
<th>Banner preview</th>
</tr>
</thead>
<tbody>
<?php foreach ($recent as $c): ?>
<tr>
<td><?= h($c['occurred_at']) ?></td>
<td><?= h($c['node_name']) ?></td>
<td><code><?= h($c['src_ip']) ?></code></td>
<td><?= (int)$c['src_port'] ?></td>
<td><code><?= (int)$c['dst_port'] ?></code></td>
<td><?= (int)$c['banner_len'] ?></td>
<td>
<?php if ($c['banner_hex']): ?>
<code title="<?= h($c['banner_hex']) ?>" style="font-size:.75rem">
<?= h(mb_substr(banner_text($c['banner_hex']), 0, 40)) ?>
</code>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<!-- Node status table -->
<div class="card">
<h2>Node status</h2>
<?php if (empty($nodes)): ?>
<p class="muted">No nodes configured. <a href="nodes.php">Add one</a>.</p>
<?php else: ?>
<table>
<thead>
<tr><th>Name</th><th>API URL</th><th>Enabled</th><th>Last fetched</th></tr>
</thead>
<tbody>
<?php foreach ($nodes as $n): ?>
<tr>
<td><a href="index.php?node=<?= (int)$n['id'] ?>"><?= h($n['name']) ?></a></td>
<td><code><?= h($n['api_url']) ?></code></td>
<td><?= $n['enabled'] ? '<span class="badge ok">yes</span>' : '<span class="badge">no</span>' ?></td>
<td><?= $n['last_fetched_at'] ? h($n['last_fetched_at']) : '<span class="muted">never</span>' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<p class="muted" style="font-size:.75rem; text-align:right">
Auto-refreshes every 30 s &nbsp;·&nbsp; <?= date('Y-m-d H:i:s') ?> server time
</p>
</main>
</body>
</html>

167
nodes.php Normal file
View File

@@ -0,0 +1,167 @@
<?php
require_once __DIR__ . '/includes/functions.php';
$errors = [];
$success = '';
// ── Handle POST ───────────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'delete') {
$id = (int)($_POST['id'] ?? 0);
if ($id > 0) {
delete_node($id);
$success = 'Node deleted.';
}
} elseif (in_array($action, ['add', 'edit'], true)) {
$id = $action === 'edit' ? (int)($_POST['id'] ?? 0) : null;
$data = [
'name' => trim($_POST['name'] ?? ''),
'api_url' => rtrim(trim($_POST['api_url'] ?? ''), '/'),
'username' => trim($_POST['username'] ?? 'admin'),
'password' => $_POST['password'] ?? '',
'verify_ssl' => isset($_POST['verify_ssl']) ? 1 : 0,
'enabled' => isset($_POST['enabled']) ? 1 : 0,
];
if ($data['name'] === '') $errors[] = 'Name is required.';
if ($data['api_url'] === '') $errors[] = 'API URL is required.';
if ($data['password'] === '' && $action === 'add') $errors[] = 'Password is required.';
// On edit, keep existing password if field left blank
if ($action === 'edit' && $data['password'] === '') {
$existing = get_node($id);
$data['password'] = $existing['password'] ?? '';
}
if (empty($errors)) {
upsert_node($data, $id);
$success = $action === 'add' ? 'Node added.' : 'Node updated.';
}
}
}
$nodes = get_all_nodes();
$edit_node = null;
if (isset($_GET['edit'])) {
$edit_node = get_node((int)$_GET['edit']);
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Nodes portspoof concentrator</title>
<style>
<?php include __DIR__ . '/includes/style.php'; ?>
</style>
</head>
<body>
<header>
<h1>portspoof<span>concentrator</span></h1>
<nav>
<a href="index.php">Dashboard</a>
<a href="nodes.php" class="active">Nodes</a>
</nav>
</header>
<main>
<?php if ($success): ?>
<div class="alert ok"><?= h($success) ?></div>
<?php endif; ?>
<?php foreach ($errors as $e): ?>
<div class="alert err"><?= h($e) ?></div>
<?php endforeach; ?>
<section class="card">
<h2><?= $edit_node ? 'Edit node' : 'Add node' ?></h2>
<form method="post">
<input type="hidden" name="action" value="<?= $edit_node ? 'edit' : 'add' ?>">
<?php if ($edit_node): ?>
<input type="hidden" name="id" value="<?= (int)$edit_node['id'] ?>">
<?php endif; ?>
<label>Name
<input type="text" name="name" required
value="<?= h($edit_node['name'] ?? '') ?>">
</label>
<label>API URL &nbsp;<small>e.g. https://192.168.1.10:8080</small>
<input type="url" name="api_url" required
value="<?= h($edit_node['api_url'] ?? '') ?>">
</label>
<label>Username
<input type="text" name="username"
value="<?= h($edit_node['username'] ?? 'admin') ?>">
</label>
<label>Password <?= $edit_node ? '<small>(leave blank to keep current)</small>' : '' ?>
<input type="password" name="password" <?= $edit_node ? '' : 'required' ?>>
</label>
<label class="inline">
<input type="checkbox" name="verify_ssl"
<?= ($edit_node['verify_ssl'] ?? 0) ? 'checked' : '' ?>>
Verify SSL certificate
</label>
<label class="inline">
<input type="checkbox" name="enabled"
<?= ($edit_node['enabled'] ?? 1) ? 'checked' : '' ?>>
Enabled
</label>
<button type="submit"><?= $edit_node ? 'Save changes' : 'Add node' ?></button>
<?php if ($edit_node): ?>
&nbsp;<a href="nodes.php">Cancel</a>
<?php endif; ?>
</form>
</section>
<section class="card">
<h2>Configured nodes</h2>
<?php if (empty($nodes)): ?>
<p class="muted">No nodes yet. Add one above.</p>
<?php else: ?>
<table>
<thead>
<tr>
<th>Name</th>
<th>API URL</th>
<th>SSL verify</th>
<th>Enabled</th>
<th>Last fetched</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($nodes as $n): ?>
<tr>
<td><?= h($n['name']) ?></td>
<td><code><?= h($n['api_url']) ?></code></td>
<td><?= $n['verify_ssl'] ? 'yes' : 'no' ?></td>
<td><?= $n['enabled'] ? '<span class="badge ok">yes</span>' : '<span class="badge">no</span>' ?></td>
<td><?= $n['last_fetched_at'] ? h($n['last_fetched_at']) : '<span class="muted">never</span>' ?></td>
<td class="actions">
<a href="nodes.php?edit=<?= (int)$n['id'] ?>">edit</a>
<form method="post" style="display:inline"
onsubmit="return confirm('Delete node <?= h(addslashes($n['name'])) ?>?')">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<button type="submit" class="link-btn danger">delete</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</section>
</main>
</body>
</html>

33
schema.sql Normal file
View File

@@ -0,0 +1,33 @@
-- portspoof_concentrator schema
CREATE TABLE IF NOT EXISTS nodes (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
api_url VARCHAR(255) NOT NULL COMMENT 'e.g. https://192.168.1.10:8080',
username VARCHAR(100) NOT NULL DEFAULT 'admin',
password VARCHAR(255) NOT NULL,
verify_ssl TINYINT(1) NOT NULL DEFAULT 0,
enabled TINYINT(1) NOT NULL DEFAULT 1,
last_fetched_at DATETIME NULL,
last_event_at DATETIME(6) NULL COMMENT 'timestamp of the newest ingested event from this node',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_api_url (api_url)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS connections (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
node_id INT UNSIGNED NOT NULL,
occurred_at DATETIME(6) NOT NULL COMMENT 'timestamp from portspoof_py',
src_ip VARCHAR(45) NOT NULL,
src_port SMALLINT UNSIGNED NOT NULL,
dst_port SMALLINT UNSIGNED NOT NULL,
banner_hex TEXT NULL,
banner_len INT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_node_occurred (node_id, occurred_at),
KEY idx_src_ip (src_ip),
KEY idx_dst_port (dst_port),
CONSTRAINT fk_conn_node FOREIGN KEY (node_id) REFERENCES nodes (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

44
setup.php Normal file
View File

@@ -0,0 +1,44 @@
<?php
/**
* First-run database setup.
* Run once from the CLI: php setup.php
* Then delete or protect this file.
*/
require_once __DIR__ . '/config.php';
$dsn = sprintf('mysql:host=%s;port=%d;charset=utf8mb4', DB_HOST, DB_PORT);
try {
$pdo = new PDO($dsn, DB_USER, DB_PASS, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
} catch (PDOException $e) {
fwrite(STDERR, "Connection failed: " . $e->getMessage() . "\n");
exit(1);
}
$pdo->exec("CREATE DATABASE IF NOT EXISTS `" . DB_NAME . "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
$pdo->exec("USE `" . DB_NAME . "`");
$schema = file_get_contents(__DIR__ . '/schema.sql');
foreach (array_filter(array_map('trim', explode(';', $schema))) as $stmt) {
$pdo->exec($stmt);
}
// Migration: add last_event_at if upgrading from an older schema
$pdo->exec(
"ALTER TABLE nodes ADD COLUMN IF NOT EXISTS
last_event_at DATETIME(6) NULL COMMENT 'timestamp of the newest ingested event from this node'
AFTER last_fetched_at"
);
// Migration: drop uq_event unique key if upgrading from an older schema
$row = $pdo->query(
"SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'connections'
AND INDEX_NAME = 'uq_event'"
)->fetchColumn();
if ($row > 0) {
$pdo->exec("ALTER TABLE connections DROP INDEX uq_event");
echo "Dropped legacy uq_event unique key.\n";
}
echo "Database '" . DB_NAME . "' and tables created/migrated successfully.\n";

71
trigger.php Normal file
View File

@@ -0,0 +1,71 @@
<?php
/**
* portspoof_concentrator HTTP fetch trigger
*
* Runs the same fetch logic as cron/fetch.php when called over HTTP.
* Requires a valid secret token, configured in config.php as TRIGGER_TOKEN.
*
* Usage:
* GET/POST /trigger.php?token=<secret>
* GET/POST /trigger.php (with header: Authorization: Bearer <secret>)
*
* Always returns JSON.
*/
require_once __DIR__ . '/includes/functions.php';
header('Content-Type: application/json');
// ── Token check ───────────────────────────────────────────────────────────────
if (TRIGGER_TOKEN === '') {
http_response_code(503);
echo json_encode(['error' => 'Trigger endpoint is disabled. Set TRIGGER_TOKEN in config.php.']);
exit;
}
$provided = '';
// Accept token from Authorization: Bearer header
$auth_header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (str_starts_with($auth_header, 'Bearer ')) {
$provided = substr($auth_header, 7);
}
// Accept token from ?token= query param (lower priority)
if ($provided === '' && isset($_REQUEST['token'])) {
$provided = $_REQUEST['token'];
}
if (!hash_equals(TRIGGER_TOKEN, $provided)) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
// ── Run fetch ─────────────────────────────────────────────────────────────────
$started_at = microtime(true);
$results = run_fetch();
$elapsed_ms = (int)round((microtime(true) - $started_at) * 1000);
$total_fetched = array_sum(array_column($results, 'fetched'));
$total_inserted = array_sum(array_column($results, 'inserted'));
$errors = array_filter($results, fn($r) => $r['error'] !== null);
http_response_code(empty($errors) ? 200 : 207);
echo json_encode([
'ok' => empty($errors),
'elapsed_ms' => $elapsed_ms,
'total_fetched' => $total_fetched,
'total_inserted' => $total_inserted,
'nodes' => array_map(fn($r) => [
'node_id' => $r['node_id'],
'name' => $r['name'],
'fetched' => $r['fetched'],
'inserted' => $r['inserted'],
'last_event_at' => $r['last_event_at'],
'error' => $r['error'],
], $results),
], JSON_PRETTY_PRINT);