From 20ed0eeadb02f00f29df021993a19acbca946a11 Mon Sep 17 00:00:00 2001 From: DAProgs Date: Wed, 11 Mar 2026 10:14:26 -0400 Subject: [PATCH] First push --- .gitignore | 7 + README.md | 399 ++++++++++++++++++++++++++++++++++++++++- cron/fetch.php | 36 ++++ includes/db.php | 18 ++ includes/functions.php | 272 ++++++++++++++++++++++++++++ includes/style.php | 88 +++++++++ index.php | 199 ++++++++++++++++++++ nodes.php | 167 +++++++++++++++++ schema.sql | 33 ++++ setup.php | 44 +++++ trigger.php | 71 ++++++++ 11 files changed, 1333 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100755 cron/fetch.php create mode 100644 includes/db.php create mode 100644 includes/functions.php create mode 100644 includes/style.php create mode 100644 index.php create mode 100644 nodes.php create mode 100644 schema.sql create mode 100644 setup.php create mode 100644 trigger.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1aa2755 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +#Password files +admin.passwd +email.json +admin.crt +admin.key +config.php + diff --git a/README.md b/README.md index 222c0af..3558f34 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,400 @@ # portspoof_concentrator -php/mysql server to gather data from portspoof_py nodes \ No newline at end of file +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 /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 `` or `.htaccess`: + +```apache +DocumentRoot /var/www/portspoof_concentrator +DirectoryIndex index.php + + + AllowOverride None + Require all granted + + +# Deny direct access to includes and cron + + Require all denied + +``` + +**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 = ;` — 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 +``` diff --git a/cron/fetch.php b/cron/fetch.php new file mode 100755 index 0000000..316784b --- /dev/null +++ b/cron/fetch.php @@ -0,0 +1,36 @@ +#!/usr/bin/env 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"; + } +} diff --git a/includes/db.php b/includes/db.php new file mode 100644 index 0000000..b85bd7e --- /dev/null +++ b/includes/db.php @@ -0,0 +1,18 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } + return $pdo; +} diff --git a/includes/functions.php b/includes/functions.php new file mode 100644 index 0000000..0ce2cca --- /dev/null +++ b/includes/functions.php @@ -0,0 +1,272 @@ +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'); +} diff --git a/includes/style.php b/includes/style.php new file mode 100644 index 0000000..bc9361d --- /dev/null +++ b/includes/style.php @@ -0,0 +1,88 @@ + +*, *::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; } diff --git a/index.php b/index.php new file mode 100644 index 0000000..102b4a4 --- /dev/null +++ b/index.php @@ -0,0 +1,199 @@ + (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; +?> + + + + + + +Dashboard – portspoof concentrator + + + +
+

portspoofconcentrator

+ +
+
+ + +
+
+
Total connections
+
+
+
+
Last s
+
+
+
+
Nodes
+
/
+
+
+
Last connection
+
+
+
+ + + 1): ?> +
+ Filter by node: +  >All + +  > + + + +
+ + + +
+
+

Top source IPs

+ +

No data yet.

+ + + + + + + + + + + + +
IPConnections
+
+
+
+
+ +
+ +
+

Top targeted ports

+ +

No data yet.

+ + + + + + + + + + + + +
PortConnections
+
+
+
+
+ +
+
+ + +
+

Recent connections

+ +

No connections ingested yet. Make sure at least one node is configured and the fetch cron is running.

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Time (UTC)NodeSource IPSrc portDst portBanner lenBanner preview
+ + + + + +
+ +
+ + +
+

Node status

+ +

No nodes configured. Add one.

+ + + + + + + + + + + + + + + +
NameAPI URLEnabledLast fetched
yes' : 'no' ?>never' ?>
+ +
+ +

+ Auto-refreshes every 30 s  ·  server time +

+ +
+ + diff --git a/nodes.php b/nodes.php new file mode 100644 index 0000000..251d9fc --- /dev/null +++ b/nodes.php @@ -0,0 +1,167 @@ + 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']); +} +?> + + + + + +Nodes – portspoof concentrator + + + +
+

portspoofconcentrator

+ +
+
+ + +
+ + +
+ + +
+

+
+ + + + + + + + + + + + + + + + + + + +  Cancel + +
+
+ +
+

Configured nodes

+ +

No nodes yet. Add one above.

+ + + + + + + + + + + + + + + + + + + + + + + + +
NameAPI URLSSL verifyEnabledLast fetched
yes' : 'no' ?>never' ?> + edit +
+ + + +
+
+ +
+ +
+ + diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..4ead280 --- /dev/null +++ b/schema.sql @@ -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; diff --git a/setup.php b/setup.php new file mode 100644 index 0000000..bd4c006 --- /dev/null +++ b/setup.php @@ -0,0 +1,44 @@ + 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"; diff --git a/trigger.php b/trigger.php new file mode 100644 index 0000000..e5c33df --- /dev/null +++ b/trigger.php @@ -0,0 +1,71 @@ + + * GET/POST /trigger.php (with header: Authorization: Bearer ) + * + * 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);