First push

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

18
includes/db.php Normal file
View File

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

272
includes/functions.php Normal file
View File

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

88
includes/style.php Normal file
View File

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