First push
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#Password files
|
||||||
|
admin.passwd
|
||||||
|
email.json
|
||||||
|
admin.crt
|
||||||
|
admin.key
|
||||||
|
config.php
|
||||||
|
|
||||||
399
README.md
399
README.md
@@ -1,3 +1,400 @@
|
|||||||
# portspoof_concentrator
|
# 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
36
cron/fetch.php
Executable 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
18
includes/db.php
Normal 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
272
includes/functions.php
Normal 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
88
includes/style.php
Normal 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
199
index.php
Normal 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>
|
||||||
|
<a href="index.php" <?= !$filter_node ? 'style="color:var(--accent)"' : '' ?>>All</a>
|
||||||
|
<?php foreach ($nodes as $n): ?>
|
||||||
|
<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 · <?= date('Y-m-d H:i:s') ?> server time
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
167
nodes.php
Normal file
167
nodes.php
Normal 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 <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): ?>
|
||||||
|
<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
33
schema.sql
Normal 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
44
setup.php
Normal 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
71
trigger.php
Normal 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);
|
||||||
Reference in New Issue
Block a user