Replace one INSERT per event (up to FETCH_LIMIT auto-committed round trips per node per poll) with multi-row INSERTs in chunks of 200, wrapped in a single transaction per node. Same data, one commit instead of hundreds, which was making fetch runs slower as the table grew.
453 lines
15 KiB
PHP
453 lines
15 KiB
PHP
<?php
|
|
require_once __DIR__ . '/db.php';
|
|
require_once __DIR__ . '/../version.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).
|
|
*
|
|
* New rows are written with multi-row INSERTs in chunks, inside a single
|
|
* transaction per call, to keep the number of round trips and commits low.
|
|
*
|
|
* 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 {
|
|
$new_max_ts = null;
|
|
$rows = [];
|
|
|
|
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;
|
|
}
|
|
|
|
$rows[] = [
|
|
$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),
|
|
];
|
|
|
|
if ($new_max_ts === null || $ts > $new_max_ts) {
|
|
$new_max_ts = $ts;
|
|
}
|
|
}
|
|
|
|
if ($rows === []) {
|
|
return ['inserted' => 0, 'new_max_ts' => null];
|
|
}
|
|
|
|
$pdo = db();
|
|
$pdo->beginTransaction();
|
|
try {
|
|
foreach (array_chunk($rows, 200) as $chunk) {
|
|
$values = implode(', ', array_fill(0, count($chunk), '(?, ?, ?, ?, ?, ?, ?)'));
|
|
$params = [];
|
|
foreach ($chunk as $r) {
|
|
$params = array_merge($params, $r);
|
|
}
|
|
$s = $pdo->prepare(
|
|
'INSERT INTO connections
|
|
(node_id, occurred_at, src_ip, src_port, dst_port, banner_hex, banner_len)
|
|
VALUES ' . $values
|
|
);
|
|
$s->execute($params);
|
|
}
|
|
$pdo->commit();
|
|
} catch (PDOException $e) {
|
|
$pdo->rollBack();
|
|
throw $e;
|
|
}
|
|
|
|
return ['inserted' => count($rows), '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;
|
|
}
|
|
|
|
// ── Settings ──────────────────────────────────────────────────────────────────
|
|
|
|
function get_setting(string $key, string $default = ''): string {
|
|
$s = db()->prepare('SELECT value FROM settings WHERE key_name = ?');
|
|
$s->execute([$key]);
|
|
$row = $s->fetchColumn();
|
|
return $row !== false ? $row : $default;
|
|
}
|
|
|
|
function set_setting(string $key, string $value): void {
|
|
$s = db()->prepare(
|
|
'INSERT INTO settings (key_name, value) VALUES (?, ?)
|
|
ON DUPLICATE KEY UPDATE value = VALUES(value)'
|
|
);
|
|
$s->execute([$key, $value]);
|
|
}
|
|
|
|
// ── Frequent IPs ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Return all IPs with a total connection count >= $min_connections, sorted
|
|
* highest-count first. Includes first and last seen timestamps.
|
|
*
|
|
* @param int $min_connections Minimum number of connections to include.
|
|
* @param string|null $since Optional ISO datetime; only IPs whose
|
|
* most-recent connection is >= this value
|
|
* are returned.
|
|
*/
|
|
function frequent_ips(int $min_connections, ?string $since = null): array {
|
|
$having = 'total_connections >= ?';
|
|
$params = [$min_connections];
|
|
|
|
if ($since !== null) {
|
|
$having .= ' AND last_seen >= ?';
|
|
$params[] = $since;
|
|
}
|
|
|
|
$s = db()->prepare(
|
|
'SELECT src_ip,
|
|
COUNT(*) AS total_connections,
|
|
MIN(occurred_at) AS first_seen,
|
|
MAX(occurred_at) AS last_seen
|
|
FROM connections
|
|
GROUP BY src_ip
|
|
HAVING ' . $having . '
|
|
ORDER BY total_connections DESC'
|
|
);
|
|
$s->execute($params);
|
|
return $s->fetchAll();
|
|
}
|
|
|
|
// ── Purge ─────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Delete connections older than retention_days setting.
|
|
* Returns the number of rows deleted.
|
|
*/
|
|
function purge_old_connections(): int {
|
|
$days = max(1, (int)get_setting('retention_days', '7'));
|
|
$s = db()->prepare(
|
|
'DELETE FROM connections WHERE occurred_at < NOW() - INTERVAL ? DAY'
|
|
);
|
|
$s->execute([$days]);
|
|
return $s->rowCount();
|
|
}
|
|
|
|
// ── Upstream version check ────────────────────────────────────────────────────
|
|
|
|
define('UPSTREAM_VERSION_URL', 'https://git.ny.daprogs.com/api/v1/repos/DAProgs/portspoof_concentrator/raw/version.php?ref=main');
|
|
define('UPSTREAM_VERSION_CACHE', __DIR__ . '/../upstream_version.cache');
|
|
define('UPSTREAM_VERSION_TTL', 3600); // seconds
|
|
|
|
/**
|
|
* Fetch the upstream version string from the git repo, with a 1-hour file cache.
|
|
* Returns null silently on any failure so the page always loads.
|
|
*/
|
|
function fetch_upstream_version(): ?string {
|
|
// Return cached value if still fresh
|
|
if (file_exists(UPSTREAM_VERSION_CACHE)) {
|
|
$cached = json_decode(file_get_contents(UPSTREAM_VERSION_CACHE), true);
|
|
if (isset($cached['version'], $cached['checked_at'])
|
|
&& (time() - $cached['checked_at']) < UPSTREAM_VERSION_TTL) {
|
|
return $cached['version'];
|
|
}
|
|
}
|
|
|
|
$ch = curl_init(UPSTREAM_VERSION_URL);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => 5,
|
|
CURLOPT_FOLLOWLOCATION => true,
|
|
]);
|
|
$body = curl_exec($ch);
|
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($body === false || $code !== 200) {
|
|
return null;
|
|
}
|
|
|
|
if (!preg_match("/define\s*\(\s*'APP_VERSION'\s*,\s*'([^']+)'\s*\)/", $body, $m)) {
|
|
return null;
|
|
}
|
|
|
|
$version = $m[1];
|
|
file_put_contents(
|
|
UPSTREAM_VERSION_CACHE,
|
|
json_encode(['version' => $version, 'checked_at' => time()]),
|
|
LOCK_EX
|
|
);
|
|
return $version;
|
|
}
|
|
|
|
/**
|
|
* Returns true if $upstream is a newer version than $local.
|
|
* Format: YYMM.N e.g. 2603.4
|
|
*/
|
|
function is_newer_version(string $upstream, string $local): bool {
|
|
$parse = fn($v) => array_map('intval', explode('.', $v, 2));
|
|
[$um, $un] = $parse($upstream);
|
|
[$lm, $ln] = $parse($local);
|
|
return $um > $lm || ($um === $lm && $un > $ln);
|
|
}
|
|
|
|
// ── API queries ───────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Return connections from the last $minutes minutes, newest first.
|
|
* Optionally filtered to a single node.
|
|
*/
|
|
function connections_since(int $minutes = 10, ?int $node_id = null): array {
|
|
$since = date('Y-m-d H:i:s', time() - $minutes * 60);
|
|
|
|
if ($node_id !== null) {
|
|
$s = db()->prepare(
|
|
'SELECT c.id, c.occurred_at, c.src_ip, c.src_port, c.dst_port,
|
|
c.banner_hex, c.banner_len, n.name AS node_name, n.id AS node_id
|
|
FROM connections c JOIN nodes n ON n.id = c.node_id
|
|
WHERE c.node_id = ? AND c.occurred_at >= ?
|
|
ORDER BY c.occurred_at DESC'
|
|
);
|
|
$s->execute([$node_id, $since]);
|
|
} else {
|
|
$s = db()->prepare(
|
|
'SELECT c.id, c.occurred_at, c.src_ip, c.src_port, c.dst_port,
|
|
c.banner_hex, c.banner_len, n.name AS node_name, n.id AS node_id
|
|
FROM connections c JOIN nodes n ON n.id = c.node_id
|
|
WHERE c.occurred_at >= ?
|
|
ORDER BY c.occurred_at DESC'
|
|
);
|
|
$s->execute([$since]);
|
|
}
|
|
return $s->fetchAll();
|
|
}
|
|
|
|
// ── 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
|
|
WHERE occurred_at >= NOW() - INTERVAL 24 HOUR
|
|
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
|
|
WHERE occurred_at >= NOW() - INTERVAL 24 HOUR
|
|
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 = ? AND occurred_at >= NOW() - INTERVAL 24 HOUR
|
|
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');
|
|
}
|