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
|
||||
|
||||
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