diff --git a/CHANGELOG.md b/CHANGELOG.md index fde7b01..293140d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,35 +2,35 @@ --- develop --- -* fix: Make bundled Net_DNS2 constructor nullability explicit for PHP 8.4 without changing accepted arguments - -* issue: Shorten the graph drilldown button title from 'View NetFlow Traffic In Range' to 'NetFlow' so it reads cleanly as a label in Cacti's graph action menu -* dev: Create the plugin's config.php from config.php.dist for the flowview install step in the integration-test workflow (removed immediately afterwards so the unit tests still run with no config file), so the install no longer fails with "needs configuration" -* feature: Add an optional FlowView Query Service (a long-running Go service under service/flowview-query) as a selectable Parallel Query Backend under FlowView Settings (default Legacy PHP Runner); when enabled, the parallel query parent runner (flowview_runner.php) delegates the whole query to the service over localhost instead of forking a PHP worker per shard, running the same cache-aware map-reduce (sharing parallel_database_query_shard_cache) and writing results back, with automatic fallback to the legacy runner when the service is unreachable - -* bug: Load the pre-1.3 report compatibility shims (reports_run/reports_queue/reports_log_and_notify) only when the running Cacti is older than 1.3, decided with cacti_version_compare(CACTI_VERSION, '1.3', ...) rather than a function_exists() probe; because the plugin's functions.php is included during plugin init before core loads lib/reports.php, the function_exists() check ran while the now-native helpers were still absent, declared the shims, and core then fatally redeclared reports_log_and_notify() on 1.3.0 develop builds (falls back to function_exists() only when the Cacti version helpers are unavailable, e.g. the unit tests) -* security: Move the plugin's remaining inline event handlers to CSP-safe bindings so the pages no longer trip Cacti's Content-Security-Policy script-src-attr directive: confirmation Cancel buttons use the `cactiReturnTo` class, the database/filter selects are bound via change() in their ready blocks, and the main filter's sort/report selects and time-shift icons are bound in flowview_display_filter's ready block -* dev: Replace the translation-template CI check that regenerated locales/po/cacti.pot and compared it with a diff-based gate (tests/bin/check-i18n-pot.php) requiring cacti.pot to be updated only when a pull request adds, removes, or modifies an i18n function call -* issue: Prevent flow-capture from fatally erroring (TypeError in spl_object_hash) when flowview_db_check_reconnect() held a null shared-database handle; normalize a non-object connection to false so db_check_reconnect() checks and reconnects the default connection instead -* dev: Measure CI coverage with xdebug instead of pcov so the plugin's own sources are instrumented (pcov auto-scopes to the Composer root and skipped cacti/plugins/, leaving the patch-coverage gate with nothing to measure) -* dev: Enforce patch coverage of changed lines in CI and remove the inert COMPOSER_ROOT_VERSION env from the Pest step -* dev: Pin the CI database image and shared test dependencies, disable persisted checkout credentials, and limit workflow permissions and runtime +* security: Move the plugin's remaining inline event handlers to CSP-safe bindings +* security: Add a version-safe CSP nonce (`plugin_flowview_csp_nonce()`) to every inline `'; diff --git a/nbar_count.php b/nbar_count.php new file mode 100644 index 0000000..a71e7d5 --- /dev/null +++ b/nbar_count.php @@ -0,0 +1,164 @@ + $entry) { + if ($entry['expires'] < time()) { + unset($cache[$k]); + } + } + if (!isset($cache[$key])) { + while (cacti_sizeof($cache) >= 12) { + array_shift($cache); + } + $cache[$key] = ['sql' => $sql, 'args' => $args, 'expires' => time() + 3600, 'ttl' => $end >= time() - 120 ? 60 : 600]; + } + $_SESSION['fv_nbar_counts'] = $cache; + return $key; +} +/** + * Send a JSON count response and terminate the HTTP request. + * + * @param array $payload JSON response fields. + * + * @return never + */ +function fv_nbar_count_response($payload) { + header('Content-Type: application/json'); + header('Cache-Control: no-store'); + echo json_encode($payload); + exit; +} +/** + * Handle an authenticated count request without blocking account navigation. + * + * @return never + */ +function fv_nbar_count_request() { + $key = get_nfilter_request_var('key'); + if (!is_string($key) || !preg_match('/^[a-f0-9]{64}$/D', $key)) { + fv_nbar_count_response(['status' => 'expired']); + } + $entry = $_SESSION['fv_nbar_counts'][$key] ?? null; + if (!$entry || $entry['expires'] < time()) { + fv_nbar_count_response(['status' => 'expired']); + } + if (($entry['failed_until'] ?? 0) > time()) { + fv_nbar_count_response(['status' => 'unavailable']); + } + if (isset($entry['total']) && $entry['until'] > time()) { + fv_nbar_count_response(['status' => 'ready', 'pages' => (int) ceil($entry['total'] / 100), 'total' => $entry['total']]); + } + // Release the account session lock so navigation remains available during counting. + session_write_close(); + $locked = flowview_db_fetch_cell("SELECT GET_LOCK(CONCAT('flowview_nbar_page_count:',LEFT(SHA2(DATABASE(),256),16)),0)"); + if ((int) $locked !== 1) { + fv_nbar_count_response(['status' => 'busy']); + } + // Return promptly; subsequent polls read the completed session cache. + // PHP-FPM can finish this bounded calculation after closing the response. + ini_set('session.use_cookies', '0'); + session_cache_limiter(''); + $background = function_exists('fastcgi_finish_request'); + if ($background) { + header('Content-Type: application/json'); + header('Cache-Control: no-store'); + echo json_encode(['status' => 'busy']); + fastcgi_finish_request(); + } + $row = null; + try { + set_time_limit(100); + $row = fv_nbar_count_query($entry['sql'], $entry['args']); + } finally { + flowview_db_fetch_cell("SELECT RELEASE_LOCK(CONCAT('flowview_nbar_page_count:',LEFT(SHA2(DATABASE(),256),16)))"); + } + if (!is_array($row) || !isset($row['total'])) { + session_start(); + if (isset($_SESSION['fv_nbar_counts'][$key])) { + $_SESSION['fv_nbar_counts'][$key]['failed_until'] = time() + 60; + } + session_write_close(); + if ($background) { + exit; + } + fv_nbar_count_response(['status' => 'unavailable']); + } + $total = (int) $row['total']; + session_start(); + if (isset($_SESSION['fv_nbar_counts'][$key])) { + $_SESSION['fv_nbar_counts'][$key]['total'] = $total; + $_SESSION['fv_nbar_counts'][$key]['until'] = time() + $entry['ttl']; + } + session_write_close(); + if ($background) { + exit; + } + fv_nbar_count_response(['status' => 'ready', 'pages' => (int) ceil($total / 100), 'total' => $total]); +} + +/** Bound a SELECT on the actual FlowView server rather than Cacti's server. */ +function fv_nbar_count_query(string $sql, array $args) { + $version = flowview_db_fetch_cell('SELECT VERSION()'); + if (is_string($version) && stripos($version, 'MariaDB') !== false) { + return flowview_db_fetch_row_prepared('SET STATEMENT max_statement_time=90 FOR ' . $sql, $args); + } + if (is_string($version) && preg_match('/^8[.]/', $version)) { + return flowview_db_fetch_row_prepared(preg_replace('/^SELECT /', 'SELECT /*+ MAX_EXECUTION_TIME(90000) */ ', $sql), $args); + } + return false; +} diff --git a/nbar_daily.php b/nbar_daily.php new file mode 100644 index 0000000..133ba28 --- /dev/null +++ b/nbar_daily.php @@ -0,0 +1,296 @@ +getTimestamp(), $day->modify('+1 day')->getTimestamp()]; +} +/** + * Require every overlapping hour to match its completed raw revision. + * + * @param int $listener Capture identifier. + * @param int $from Inclusive boundary in Unix seconds. + * @param int $to Exclusive boundary in Unix seconds. + * + * @return string|null + */ +function fv_nbar_daily_signature($listener, $from, $to) { + $lo = (int) (floor($from / 3600) * 3600); + $hi = (int) (ceil($to / 3600) * 3600); + $r = flowview_db_fetch_row_prepared('SELECT COUNT(*) n, COALESCE(SUM(revision),0) rev, + COALESCE(SUM(built=revision AND connection_built=revision AND built>=0),0) ready + FROM plugin_flowview_nbar_hours WHERE listener_id=? AND hour>=? AND hour=0),0)=$count + FROM plugin_flowview_nbar_hours WHERE listener_id=$listener AND hour>=$lo AND hour<$hi)"; +} +/** + * Add one hour atomically or restart a day invalidated by late records. + * + * @param int $listener Capture identifier. + * @param int $day Local calendar-day start in Unix seconds. + * @param int $target Requested exclusive summary boundary. + * + * @return int + */ +function fv_nbar_daily_build($listener, $day, $target) { + $listener = (int) $listener; + $day = (int) $day; + $target = (int) $target; + if (!flowview_db_execute('START TRANSACTION WITH CONSISTENT SNAPSHOT')) { + return 0; + } + $old = flowview_db_fetch_row_prepared('SELECT * FROM plugin_flowview_nbar_days WHERE listener_id=? AND day=?', [$listener, $day]); + $valid = $old && $old['timezone'] === date_default_timezone_get() && (int) $old['covered_to'] <= $target && + fv_nbar_daily_signature($listener, $day, (int) $old['covered_to']) === (string) $old['revision']; + $from = $valid ? (int) $old['covered_to'] : $day; + if ($from >= $target) { + flowview_db_execute('ROLLBACK'); + return 0; + } + $to = min($target, (int) (floor($from / 3600) * 3600 + 3600)); + $revision = fv_nbar_daily_signature($listener, $day, $to); + if ($revision === null) { + flowview_db_execute('ROLLBACK'); + return 0; + } + $statement_timeout = fv_nbar_worker_timeout(20); + if ($statement_timeout === null) { + flowview_db_execute('ROLLBACK'); + return 0; + } + try { + foreach (['apps' => 'app_id,LENGTH(src)', 'pairs' => 'app_id,src,dst', 'connections' => 'app_id,src,dst,sport,dport,protocol'] as $kind => $fields) { + if (!$valid && !flowview_db_execute_prepared("DELETE FROM plugin_flowview_nbar_daily_$kind WHERE listener_id=? AND hour=?", [$listener, $day])) { + throw new RuntimeException('Daily reset failed'); + } + $source = fv_nbar_hourly_source($kind, [$listener], $from, $to); + $sql = "INSERT INTO plugin_flowview_nbar_daily_$kind SELECT $listener,$day,$fields,SUM(bytes),SUM(packets),SUM(flows),IF(COUNT(first_seen)=COUNT(*),MIN(first_seen),NULL),IF(COUNT(last_seen)=COUNT(*),MAX(last_seen),NULL),router + FROM $source d WHERE d.listener_id=$listener AND d.minute>=$from AND d.minute<$to GROUP BY $fields,router + ON DUPLICATE KEY UPDATE bytes=bytes+VALUES(bytes),packets=packets+VALUES(packets),flows=flows+VALUES(flows),first_seen=LEAST(first_seen,VALUES(first_seen)),last_seen=GREATEST(last_seen,VALUES(last_seen))"; + if (!flowview_db_execute($sql)) { + throw new RuntimeException('Daily aggregate failed'); + } + } + if (!flowview_db_execute_prepared( + 'INSERT INTO plugin_flowview_nbar_days + (listener_id,day,covered_to,revision,timezone,updated) VALUES (?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE covered_to=VALUES(covered_to),revision=VALUES(revision),timezone=VALUES(timezone),updated=VALUES(updated)', + [$listener, $day, $to, $revision, date_default_timezone_get(), time()] + )) { + throw new RuntimeException('Daily checkpoint failed'); + } + if (!flowview_db_execute('COMMIT')) { + throw new RuntimeException('Daily commit failed'); + } + return 1; + } catch (Throwable $e) { + flowview_db_execute('ROLLBACK'); + cacti_log('NBAR daily summary: ' . $e->getMessage(), false, 'FLOWVIEW'); + return 0; + } finally { + flowview_db_execute('SET SESSION max_statement_time=' . $statement_timeout); + } +} +/** + * Maintain reusable calendar-day summaries within a worker budget. + * + * @param int $budget Worker time budget in seconds. + * + * @return int + */ +function fv_nbar_daily_refresh($budget = 20) { + if ((int) flowview_db_fetch_cell("SELECT GET_LOCK(CONCAT('flowview_nbar_daily:',LEFT(SHA2(DATABASE(),256),16)),0)") !== 1) { + return 0; + } + $deadline = microtime(true) + max(1, (int) $budget); + $done = 0; + try { + $cutoff = time() - fv_nbar_retention_days() * 86400; + $closed = (int) (floor((time() - 300) / 3600) * 3600); + $listeners = flowview_db_fetch_assoc('SELECT listener_id,MIN(hour) first_hour FROM plugin_flowview_nbar_hours GROUP BY listener_id'); + $candidates = []; + foreach ($listeners as $row) { + $first_record = flowview_db_fetch_cell_prepared('SELECT minute FROM plugin_flowview_nbar_data WHERE listener_id=? ORDER BY minute LIMIT 1', [(int) $row['listener_id']]); + if (!$first_record) { + continue; + } + $row['first_hour'] = (int) (floor((int) $first_record / 3600) * 3600); + [$first] = fv_nbar_daily_bounds(max($cutoff, (int) $first_record)); + if ($first >= $cutoff) { + for ($empty = (int) (floor($first / 3600) * 3600);$empty < (int) $row['first_hour'];$empty += 3600) { + flowview_db_execute_prepared('INSERT IGNORE INTO plugin_flowview_nbar_hours (listener_id,hour,revision,built,connection_built) VALUES (?,?,0,0,0)', [(int) $row['listener_id'], $empty]); + } + } + [$today] = fv_nbar_daily_bounds($closed); + for ($day = $first;$day <= $today;$day = $next) { + [, $next] = fv_nbar_daily_bounds($day); + if ($day < $cutoff) { + continue; + } + $candidates[] = [(int) $row['listener_id'], $day, min($next, $closed)]; + } + } + // Finish older days first; each checkpoint appends only the next hour. + usort($candidates, static function ($a, $b) {return $a[1] <=> $b[1] ?: $a[0] <=> $b[0];}); + do { + $progress = 0; + foreach ($candidates as [$listener,$day,$target]) { + if (microtime(true) >= $deadline) { + break 2; + } + $progress += fv_nbar_daily_build($listener, $day, $target); + } + $done += $progress; + } while ($progress && microtime(true) < $deadline); + } finally { + flowview_db_fetch_cell("SELECT RELEASE_LOCK(CONCAT('flowview_nbar_daily:',LEFT(SHA2(DATABASE(),256),16)))"); + } + return $done; +} +/** + * Expire daily summary rows and checkpoints in bounded batches. + * + * @return void + */ +function fv_nbar_daily_cleanup() { + fv_nbar_cleanup_batches(fv_nbar_daily_cleanup_queries(), microtime(true) + 10); +} +/** Return allowlisted daily retention batches for the shared cleanup worker. */ +function fv_nbar_daily_cleanup_queries(): array { + $cutoff = time() - fv_nbar_retention_days() * 86400; + $queries = []; + foreach (['apps', 'pairs', 'connections'] as $kind) { + $queries[] = ["DELETE FROM plugin_flowview_nbar_daily_$kind WHERE hour<$cutoff LIMIT 5000", 5000]; + } + $queries[] = ["DELETE FROM plugin_flowview_nbar_days WHERE day<$cutoff LIMIT 100", 100]; + return $queries; +} +/** + * Use valid full-day summaries and hourly or raw boundary records. + * + * @param string $kind Allowlisted summary type. + * @param array $listeners Selected capture identifiers. + * @param int $start Inclusive range start in Unix seconds. + * @param int $end Exclusive range end in Unix seconds. + * @param int|null $now Current Unix time, or null to read the clock. + * + * @return string + */ +function fv_nbar_summary_source($kind, $listeners, $start, $end, $now = null) { + if (!in_array($kind, ['apps', 'pairs', 'connections'], true)) { + throw new InvalidArgumentException('Unknown summary kind'); + } + $listeners = array_values(array_unique(array_filter(array_map('intval', $listeners), static function ($id) {return $id > 0;}))); + if (!$listeners) { + return '(SELECT listener_id,minute,app_id,src,dst,sport,dport,protocol,bytes,packets,flows,minute first_seen,minute last_seen,router FROM plugin_flowview_nbar_data)'; + } + $start = (int) $start; + $end = (int) $end; + $now = $now ?? time(); + if ($end - $start < 3600) { + return fv_nbar_hourly_source($kind, $listeners, $start, $end, $now); + } + $parts = []; + foreach ($listeners as $listener) { + $days = flowview_db_fetch_assoc_prepared('SELECT * FROM plugin_flowview_nbar_days WHERE listener_id=? AND day>=? AND covered_to<=? AND covered_to>day ORDER BY day', [$listener, $start, $end]); + $cursor = $start; + foreach ($days as $row) { + $day = (int) $row['day']; + $to = (int) $row['covered_to']; + if ($row['timezone'] !== date_default_timezone_get() || $day < $cursor || $to > (int) (floor(($now - 300) / 3600) * 3600)) { + continue; + } + if ($day > $cursor) { + $s = fv_nbar_hourly_source($kind, [$listener], $cursor, $day, $now); + $parts[] = "SELECT * FROM $s fallback_before WHERE listener_id=$listener AND minute>=$cursor AND minute<$day"; + } + $ready = fv_nbar_daily_guard($listener, $day, $to, $row['revision']); + if ($kind === 'apps') { + $fields = 'listener_id,hour minute,app_id,REPEAT(CHAR(0),family) src,REPEAT(CHAR(0),family) dst,0 sport,0 dport,0 protocol,bytes,packets,flows,first_seen,last_seen,router'; + } elseif ($kind === 'pairs') { + $fields = 'listener_id,hour minute,app_id,src,dst,0 sport,0 dport,0 protocol,bytes,packets,flows,first_seen,last_seen,router'; + } else { + $fields = 'listener_id,hour minute,app_id,src,dst,sport,dport,protocol,bytes,packets,flows,first_seen,last_seen,router'; + } + $parts[] = "SELECT $fields FROM plugin_flowview_nbar_daily_$kind WHERE listener_id=$listener AND hour=$day AND ($ready)"; + $s = fv_nbar_hourly_source($kind, [$listener], $day, $to, $now); + $parts[] = "SELECT * FROM $s fallback_day WHERE listener_id=$listener AND minute>=$day AND minute<$to AND NOT ($ready)"; + $cursor = $to; + } + if ($cursor < $end) { + $s = fv_nbar_hourly_source($kind, [$listener], $cursor, $end, $now); + $parts[] = "SELECT * FROM $s fallback_after WHERE listener_id=$listener AND minute>=$cursor AND minute<$end"; + } + } + return '(' . implode(' UNION ALL ', $parts) . ')'; +} diff --git a/nbar_dns.php b/nbar_dns.php new file mode 100644 index 0000000..5f359e0 --- /dev/null +++ b/nbar_dns.php @@ -0,0 +1,101 @@ + time()) { + return $names[$ip] = $negative[$ip]['name']; + } + if ($started === null) { + $started = microtime(true); + } + if (microtime(true) - $started > 1) { + return $names[$ip] = __('Not resolved', 'flowview'); + } + if ($resolver === null) { + set_include_path($config['base_path'] . '/plugins/flowview' . PATH_SEPARATOR . get_include_path()); + include_once ($config['base_path'] . '/plugins/flowview/Net/DNS2.php'); + $servers = array_filter([read_config_option('settings_dns_primary'), read_config_option('settings_dns_secondary')]); + $resolver = new Net_DNS2_Resolver($servers ? ['nameservers' => array_values($servers)] : []); + $resolver->timeout = 1; + } + try { + $response = $resolver->query($ip, 'PTR'); + foreach ($response->answer as $answer) { + if (!empty($answer->ptrdname)) { + $name = rtrim($answer->ptrdname, '.'); + flowview_db_execute_prepared('INSERT INTO plugin_flowview_dnscache (ip,host,source,arin_verified,arin_id,time) VALUES (?,?,?,0,0,?) ON DUPLICATE KEY UPDATE host=VALUES(host),source=VALUES(source),time=VALUES(time)', [$ip, $name, 'DNS', time()]); + return $names[$ip] = $name; + } + } + return $names[$ip] = fv_nbar_dns_negative($ip, __('No reverse DNS', 'flowview'), 300); + } catch (Exception $e) { + return $names[$ip] = fv_nbar_dns_negative($ip, $e->getCode() === 3 ? __('No reverse DNS', 'flowview') : __('Not resolved', 'flowview'), $e->getCode() === 3 ? 300 : 30); + } +} + +/** + * Cache a negative DNS result with bounded account-session storage. + * + * @param string $ip Endpoint IP address. + * @param string $name Cached DNS result label. + * @param int $ttl Cache lifetime in seconds. + * + * @return string + */ +function fv_nbar_dns_negative($ip, $name, $ttl) { + $cache = $_SESSION['fv_nbar_dns_negative'] ?? []; + foreach ($cache as $key => $entry) { + if ($entry['until'] <= time()) { + unset($cache[$key]); + } + } + $cache[$ip] = ['name' => $name, 'until' => time() + $ttl]; + if (cacti_sizeof($cache) > 512) { + $cache = array_slice($cache, -512, null, true); + } + $_SESSION['fv_nbar_dns_negative'] = $cache; + return $name; +} diff --git a/nbar_feed_status.php b/nbar_feed_status.php new file mode 100644 index 0000000..2d5e297 --- /dev/null +++ b/nbar_feed_status.php @@ -0,0 +1,62 @@ +' . fv_nbar_h(__('STATUS:', 'flowview')) . ' '; + $items = []; + foreach ($devices as $device) { + $id = (int) $device['id']; + if (!in_array($id, $capture_ids, true)) { + continue; + } + // Indexed lookup independent of historical report filters. + $latest = flowview_db_fetch_row_prepared('SELECT minute FROM plugin_flowview_nbar_data WHERE listener_id=? ORDER BY minute DESC LIMIT 1', [$id]); + if (!is_array($latest)) { + $items[] = '' . fv_nbar_h($device['name']) . ': ' . fv_nbar_h(__('Unable to load NBAR status. Please try again.', 'flowview')) . ''; + continue; + } + $last = isset($latest['minute']) ? (int) $latest['minute'] : 0; + $receiving = $last > 0 && $last >= $now - 300 && $last <= $now + 60; + $label = $receiving ? __('Receiving', 'flowview') : __('No recent NBAR', 'flowview'); + $seen = $last > 0 ? __('Last NBAR record: %s', date('Y-m-d H:i', $last), 'flowview') : __('No retained NBAR records', 'flowview'); + $icon = $receiving ? 'fa-check-circle' : 'fa-times-circle'; + $color = $receiving ? '#27852b' : '#d32f2f'; + $items[] = ' ' . fv_nbar_h($device['name']) . ': ' . fv_nbar_h($label) . ' (' . fv_nbar_h($seen) . ')'; + } + echo implode('   |   ', $items); + if (!$items) { + echo fv_nbar_h(__('No captures selected', 'flowview')); + } + echo ''; +} diff --git a/nbar_preferences.php b/nbar_preferences.php new file mode 100644 index 0000000..096fe87 --- /dev/null +++ b/nbar_preferences.php @@ -0,0 +1,186 @@ + strlen($packed) * 8) { + return false; + } + $out[] = inet_ntop($packed) . '/' . $bits; + if (cacti_sizeof($out) > 32) { + return false; + } + } + return array_values(array_unique($out)); +} +/** + * Load account preferences and validate a protected settings submission. + * + * @param array $devices Available capture definitions. + * + * @return array + */ +function fv_nbar_preferences_init($devices) { + $defaults = ['prefixes' => [], 'listeners' => [], 'data_unit' => 'GB', 'rate_unit' => 'Mbps', 'dns' => '1']; + $saved = json_decode((string) read_user_setting('flowview_nbar_preferences', ''), true); + $p = array_replace($defaults, is_array($saved) ? $saved : ($_SESSION['fv_nbar_preferences'] ?? [])); + if (!is_array($p['prefixes']) || !fv_nbar_cidrs(implode("\n", $p['prefixes']))) { + $p['prefixes'] = $defaults['prefixes']; + } + $p['listeners'] = is_array($p['listeners']) ? array_values(array_intersect(array_map('intval', $p['listeners']), array_map('intval', array_column($devices, 'id')))) : $defaults['listeners']; + if (!$p['listeners']) { + $p['listeners'] = $defaults['listeners']; + } + $message = ''; + $GLOBALS['fv_nbar_preferences_saved'] = false; + if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST' && get_nfilter_request_var('prefs_apply') === '1') { + if (!function_exists('csrf_check') || !csrf_check(false)) { + http_response_code(403); + exit; + } + $candidate = $p; + $prefixes = is_string(get_nfilter_request_var('local_prefixes', null)) ? fv_nbar_cidrs(get_nfilter_request_var('local_prefixes')) : false; + $ids = is_array(get_nfilter_request_var('site_listeners', null)) ? get_nfilter_request_var('site_listeners') : []; + $ids = array_filter($ids, 'is_scalar'); + $ids = array_values(array_intersect(array_map('intval', $ids), array_map('intval', array_column($devices, 'id')))); + if ($prefixes === false) { + $message = __('Settings were not saved. Enter valid IPv4/IPv6 networks, or leave the list blank.', 'flowview'); + } elseif (cacti_sizeof(array_unique($ids)) > 3 || (isset_request_var('combine_apply') && cacti_sizeof(array_unique($ids)) === 1)) { + $message = __('Settings were not saved. Choose two or three captures to combine, or clear all selections to save Internal Networks only.', 'flowview'); + } else { + $candidate['prefixes'] = $prefixes; + $candidate['listeners'] = array_values(array_unique($ids)); + foreach (['data_unit' => ['Auto', 'MB', 'GB'], 'rate_unit' => ['Auto', 'Kbps', 'Mbps'], 'dns' => ['0', '1']] as $key => $allowed) { + if (is_string(get_nfilter_request_var($key, null)) && in_array(get_nfilter_request_var($key), $allowed, true)) { + $candidate[$key] = get_nfilter_request_var($key); + } + } + $_SESSION['fv_nbar_preferences'] = $p = $candidate; + $GLOBALS['fv_nbar_preferences_saved'] = true; + set_user_setting('flowview_nbar_preferences', json_encode($p)); + $message = ''; + } + } + $GLOBALS['fv_nbar_preferences'] = $p; + $GLOBALS['fv_nbar_preferences_message'] = $message; + return $p; +} +/** + * Build the internal-network predicate for a permitted address column. + * + * @param string $f Permitted address-column name. + * + * @return string + */ +function fv_nbar_local_sql($f) { + if (!in_array($f, ['d.src', 'd.dst'], true)) { + throw new InvalidArgumentException('Invalid address field'); + } + $clauses = []; + foreach ($GLOBALS['fv_nbar_preferences']['prefixes'] as $cidr) { + [$ip,$bits] = explode('/', $cidr); + $packed = inet_pton($ip); + $bits = (int) $bits; + $whole = intdiv($bits, 8); + $rest = $bits % 8; + $checks = ['LENGTH(' . $f . ')=' . strlen($packed)]; + if ($whole) { + $checks[] = "LEFT(HEX($f)," . ($whole * 2) . ")='" . strtoupper(bin2hex(substr($packed, 0, $whole))) . "'"; + } + if ($rest) { + $mask = (255 << (8 - $rest)) & 255; + $value = ord($packed[$whole]) & $mask; + $checks[] = "(ORD(SUBSTRING($f," . ($whole + 1) . ",1)) & $mask)=$value"; + } + $clauses[] = '(' . implode(' AND ', $checks) . ')'; + } + return $clauses ? '(' . implode(' OR ', $clauses) . ')' : '(0)'; +} +/** + * Render protected capture options, networks and display preferences. + * + * @param array $devices Available capture definitions. + * @param string $base Report base URL. + * @param string $mode Current report mode. + * @param string $aid Current application identifier. + * @param string $parent_app Parent application drilldown identifier. + * + * @return void + */ +function fv_nbar_preferences_render($devices, $base, $mode, $aid, $parent_app) { + global $config; + $p = $GLOBALS['fv_nbar_preferences']; + echo '
'; + if ($GLOBALS['fv_nbar_preferences_message'] !== '') { + echo '

' . fv_nbar_h($GLOBALS['fv_nbar_preferences_message']) . '

'; + } + echo '
'; + foreach (['data_unit' => [__('Data units', 'flowview'), ['Auto', 'MB', 'GB']], 'rate_unit' => [__('Bandwidth units', 'flowview'), ['Auto', 'Kbps', 'Mbps']], 'dns' => [__('DNS names', 'flowview'), ['1' => __('Show', 'flowview'), '0' => __('Hide', 'flowview')]]] as $key => $definition) { + echo '
'; + } + echo '
' . html_escape(__('Options', 'flowview')) . '

' . html_escape(__('Combine Options', 'flowview')) . '

'; + foreach ($devices as $d) { + echo ''; + } + echo '' . html_escape(__('Choose two or three captures to combine, normally one LAN capture per router. Leave all captures unticked to save Internal Networks only. Overlapping LAN and WAN captures are added together and can count traffic twice.', 'flowview')) . '
'; + echo '

' . html_escape(__('One internal IPv4 or IPv6 network per line. Used to identify internal hosts and upload/download direction in Conversations. Update delegated IPv6 prefixes when they change, and keep old prefixes for retained history. Exported flows do not automatically identify your site networks.', 'flowview')) . '

'; + echo ''; + echo ''; + +} + +/** Exclude multicast endpoints from internal/external conversations. */ +function fv_nbar_unicast_sql(string $field): string { + if (!in_array($field, ['d.src', 'd.dst'], true)) { + throw new InvalidArgumentException('Invalid address field'); + } + return "NOT ((LENGTH($field)=4 AND (ORD(SUBSTRING($field,1,1)) & 240)=224) OR (LENGTH($field)=16 AND ORD(SUBSTRING($field,1,1))=255))"; +} diff --git a/nbar_query.php b/nbar_query.php new file mode 100644 index 0000000..7f11e8e --- /dev/null +++ b/nbar_query.php @@ -0,0 +1,88 @@ + $entry) { + if ($entry['until'] <= $now) { + unset($cache[$id]); + } + } + if (isset($cache[$key])) { + $_SESSION['fv_nbar_query_cache'] = $cache; + return $slice === null ? $cache[$key]['rows'] : array_slice($cache[$key]['rows'], $slice, 101); + } + $rows = flowview_db_fetch_assoc_prepared($sql, $args); + // Cache successful, compact result sets only; never retain query failures. + if (is_array($rows) && strlen(serialize($rows)) <= 524288) { + while (cacti_sizeof($cache) >= 12) { + array_shift($cache); + } + $cache[$key] = ['until' => time() + ($slice === null ? 30 : 120), 'rows' => $rows]; + } + $_SESSION['fv_nbar_query_cache'] = $cache; + return $slice === null || !is_array($rows) ? $rows : array_slice($rows, $slice, 101); +} + +/** + * Build an exact grouped connection-page query over a trusted source. + * + * @param string $source Trusted summary or raw SQL source. + * @param string $where Validated filter predicate. + * @param int $page One-based connection page. + * + * @return string + */ +function fv_nbar_connections_sql($source, $where, $page) { + // Use compact name ranks while grouping; resolve display names after paging. + $map = '(SELECT router,app_id,DENSE_RANK() OVER (ORDER BY name) name_id FROM plugin_flowview_nbar_names)'; + $group = 'd.app_id,COALESCE(n.name_id,0),d.src,d.dst,d.sport,d.dport,d.protocol,d.router'; + $inner = 'SELECT d.router,d.app_id,COALESCE(n.name_id,0) name_id,SUM(d.bytes) bytes,SUM(d.packets) packets,SUM(d.flows) records,IF(COUNT(d.first_seen)=COUNT(*),MIN(d.first_seen),NULL) first_seen,IF(COUNT(d.last_seen)=COUNT(*),MAX(d.last_seen),NULL) last_seen,d.src,d.dst,d.sport,d.dport,d.protocol FROM ' . $source . ' d JOIN plugin_flowview_devices l ON l.id=d.listener_id LEFT JOIN ' . $map . ' n ON n.router=COALESCE(NULLIF(d.router,SPACE(0)),l.allowfrom) AND n.app_id=d.app_id WHERE ' . $where . ' GROUP BY ' . $group . ' ORDER BY bytes DESC,' . $group . ' LIMIT 101 OFFSET ' . (($page - 1) * 100); + return 'SELECT d.router,d.app_id,n.name,d.bytes,d.packets,d.records,d.first_seen,d.last_seen,INET6_NTOA(d.src) src,INET6_NTOA(d.dst) dst,d.sport,d.dport,d.protocol FROM (' . $inner . ') d LEFT JOIN (SELECT name,DENSE_RANK() OVER (ORDER BY name) name_id FROM plugin_flowview_nbar_names GROUP BY name) n ON n.name_id=d.name_id ORDER BY d.bytes DESC,d.app_id,d.name_id,d.src,d.dst,d.sport,d.dport,d.protocol,d.router'; +} diff --git a/nbar_report.php b/nbar_report.php new file mode 100644 index 0000000..b2ba035 --- /dev/null +++ b/nbar_report.php @@ -0,0 +1,378 @@ +' . html_escape(__('NBAR history is being upgraded. Refresh this report shortly.', 'flowview')) . '

'; + bottom_footer(); + exit; +} +// Report controls use GET links or a protected POST form. +$nbar_request = []; +foreach (['listener', 'period', 'family', 'mode', 'app', 'host', 'page', 'parent_app', 'context', 'predefined_timespan', 'date1', 'date2', 'lock_range', 'internal', 'external'] as $key) { + $value = get_nfilter_request_var($key, null); + if (is_scalar($value)) { + $nbar_request[$key] = (string) $value; + } +} +require_once __DIR__ . '/nbar_common.php'; + +require_once __DIR__ . '/nbar_dns.php'; + +include __DIR__ . '/includes/arrays.php'; +$devices = flowview_db_fetch_assoc("SELECT id,name FROM plugin_flowview_devices WHERE enabled='on' ORDER BY id"); +include_once __DIR__ . '/nbar_preferences.php'; +$nbar_preferences = fv_nbar_preferences_init($devices); +$lid = (int) ($nbar_request['listener'] ?? ($devices[0]['id'] ?? 0)); +$valid = array_column($devices, 'id'); +if ($lid !== 0 && !in_array($lid, $valid)) { + $lid = (int) ($valid[0] ?? 0); +} +$seconds = (int) ($nbar_request['period'] ?? 300); +if (!in_array($seconds, [300, 900, 3600, 86400, 604800], true)) { + $seconds = 300; +} +$family = (string) ($nbar_request['family'] ?? 'all'); +if (!in_array($family, ['all', '4', '6'], true)) { + $family = 'all'; +} +$mode = (string) ($nbar_request['mode'] ?? 'conversations'); +if (!in_array($mode, ['apps', 'conversations', 'connections'], true)) { + $mode = 'conversations'; +} +$combined_lids = array_values(array_intersect($nbar_preferences['listeners'], array_map('intval', $valid))); +if (!empty($GLOBALS['fv_nbar_preferences_saved']) && isset_request_var('combine_apply') && cacti_sizeof($combined_lids) >= 2) { + $lid = 0; +} +if ($lid === 0 && !$combined_lids) { + $lid = (int) ($valid[0] ?? 0); +} +$listener_scope = $lid === 0 ? 'd.listener_id IN (' . implode(',', $combined_lids) . ')' : 'd.listener_id=?'; +$aid = (string) ($nbar_request['app'] ?? ''); +if (!preg_match('/^[a-f0-9]{2,32}$/D', $aid)) { + $aid = ''; +} +$host = (string) ($nbar_request['host'] ?? ''); +if (!filter_var($host, FILTER_VALIDATE_IP)) { + $host = ''; +} +$page = max(1, min(1000000, (int) ($nbar_request['page'] ?? 1))); +$parent_app = (string) ($nbar_request['parent_app'] ?? ''); +if (!preg_match('/^[a-f0-9]{2,32}$/D', $parent_app)) { + $parent_app = ''; +} +$end = (int) (floor(time() / 60) * 60) - 60; +$start = $end - $seconds; +include_once $config['base_path'] . '/lib/time.php'; +$timespan_context = json_decode((string) ($nbar_request['context'] ?? ''), true); +$timespan = (int) ($nbar_request['predefined_timespan'] ?? ($timespan_context['predefined_timespan'] ?? 0)); +$custom_start = strtotime((string) ($nbar_request['date1'] ?? '')); +$custom_end = strtotime((string) ($nbar_request['date2'] ?? '')); +$has_dates = $custom_start && $custom_end && $custom_end > $custom_start; +$custom = $has_dates; +if (!isset($nbar_request['predefined_timespan']) && !isset($timespan_context['predefined_timespan']) && !$custom) { + $legacy = [3600 => GT_LAST_HOUR, 86400 => GT_LAST_DAY, 604800 => GT_LAST_WEEK]; + if (isset($nbar_request['period'])) { + $timespan = $legacy[$seconds] ?? 0; + } else { + $timespan = GT_LAST_HALF_HOUR; + } +} +if ($timespan > 0 && $timespan !== 1000 && !isset($graph_timespans[$timespan])) { + $timespan = GT_LAST_HOUR; +} +include_once __DIR__ . '/nbar_timespan.php'; +$nbar_now = (int) (floor(time() / 60) * 60); +$nbar_retention_days = fv_nbar_retention_days(); +$nbar_timespans = fv_nbar_timespan_options($graph_timespans, $nbar_now, $nbar_retention_days, read_user_setting('first_weekdayid')); +$nbar_range_notice = ''; +if ($timespan > 0 && !isset($nbar_timespans[$timespan])) { + $timespan = GT_LAST_HALF_HOUR; + $nbar_range_notice = __('That period is outside the %s-day NBAR retention setting. Showing the last half hour.', $nbar_retention_days, 'flowview'); + unset($nbar_request['lock_range']); +} +if ($timespan === 1000) { + $end = $nbar_now; + $start = $end - $nbar_retention_days * 86400; + $custom = false; +} elseif ($timespan > 0) { + get_timespan($nbar_span, $nbar_now, $timespan, read_user_setting('first_weekdayid')); + $start = strtotime($nbar_span['current_value_date1']); + $end = strtotime($nbar_span['current_value_date2']); + $custom = false; +} else { + if ($custom) { + $start = $custom_start; + $end = $custom_end; + } else { + $end = (int) (floor(time() / 60) * 60); + $start = $end - $seconds; + } + $timespan = 0; + $custom = true; +} +if ($timespan === 0 && $has_dates) { + $start = $custom_start; + $end = $custom_end; +} +if (!empty($nbar_request['lock_range']) && $has_dates) { + $start = $custom_start; + $end = $custom_end; +} +[$start, $end, $nbar_dates_adjusted] = fv_nbar_bound_dates($start, $end, $nbar_now, $nbar_retention_days); +if ($nbar_dates_adjusted) { + $timespan = 0; + $custom = true; + $nbar_range_notice = __('Dates adjusted to the %s-day NBAR retention window.', $nbar_retention_days, 'flowview'); +} +// The primary key starts with capture/minute, so each lookup reads its first record. +$nbar_earliest = null; +foreach ($lid === 0 ? $combined_lids : [$lid] as $nbar_capture_id) { + $nbar_first = flowview_db_fetch_row_prepared('SELECT minute FROM plugin_flowview_nbar_data WHERE listener_id=? ORDER BY minute LIMIT 1', [$nbar_capture_id]); + if (isset($nbar_first['minute'])) { + $nbar_first_minute = max($nbar_now - $nbar_retention_days * 86400, (int) $nbar_first['minute']); + $nbar_earliest = $nbar_earliest === null ? $nbar_first_minute : min($nbar_earliest, $nbar_first_minute); + } +} +$seconds = max(1, $end - $start); +$context = json_decode((string) ($nbar_request['context'] ?? ''), true); +if (!is_array($context)) { + $context = []; +} +$allowed = ['query', 'report', 'predefined_timespan', 'ex_addr', 'sortfield', 'cutofflines', 'cutoffoctets', 'exclude', 'graph_type', 'graph_height', 'date1', 'date2', 'domains']; +$context = array_intersect_key($context, array_flip($allowed)); +foreach ($context as $key => $value) { + if (!is_scalar($value)) { + unset($context[$key]); + } +} +if (!preg_match('/^[sp][0-9]+$/', (string) ($context['report'] ?? ''))) { + $context['report'] = 's10'; +} +$report_key = (int) substr($context['report'], 1); +if (!isset(($context['report'][0] === 's' ? $stat_report_array : $print_report_array)[$report_key])) { + $context['report'] = 's10'; +} +$context_json = json_encode($context); +$base = 'flowview.php?' . http_build_query(['action' => 'view', 'report' => 'nbar', 'listener' => $lid, 'predefined_timespan' => $timespan, 'lock_range' => 1, 'family' => $family, 'date1' => date('Y-m-d H:i', $start), 'date2' => date('Y-m-d H:i', $end), 'context' => $context_json, 'host' => $host]); +$return = $context; +$return['action'] = 'view'; +$return['device_id'] = $lid; +$return['date1'] = date('Y-m-d H:i', $start); +$return['date2'] = date('Y-m-d H:i', $end); +$return['predefined_timespan'] = $timespan; +$return['section'] = 'flows'; +$return_url = 'flowview.php?' . http_build_query($return); +parse_str(parse_url($base, PHP_URL_QUERY), $section_params); +$section_params['mode'] = $mode; +fv_section_save('nbar', $section_params); +fv_section_navigation('nbar'); +html_start_box(__('Application Report: Cisco Apps Recognition (NBAR)', 'flowview'), '100%', '', '3', 'center', ''); +echo ''; +include_once __DIR__ . '/nbar_feed_status.php'; +fv_nbar_feed_status($devices, array_map('intval', $lid === 0 ? $combined_lids : [$lid])); +echo ''; +echo '
'; +echo ''; +foreach (['action' => 'view', 'report' => 'nbar', 'context' => $context_json, 'app' => $aid, 'parent_app' => $parent_app] as $key => $value) { + echo ''; +} +foreach (['internal', 'external'] as $f) { + if ($mode === 'connections' && filter_var($nbar_request[$f] ?? '', FILTER_VALIDATE_IP)) { + echo ''; + } +} +echo '
' . html_escape(__('Router / capture', 'flowview')) . '' . html_escape(__('Clear', 'flowview')) . '
'; +echo '
' . html_escape(__('IP version', 'flowview')) . '' . html_escape(__('Show', 'flowview')) . '
'; +echo '
' . html_escape(__('Filter by IP', 'flowview')) . '
'; +echo '
' . html_escape(__('Timespan', 'flowview')) . '' . html_escape(__('From', 'flowview')) . '' . html_escape(__('To', 'flowview')) . '
'; +if ($nbar_range_notice !== '') { + echo '

' . fv_nbar_h($nbar_range_notice) . '

'; +} +fv_nbar_preferences_render($devices, $base, $mode, $aid, $parent_app); +echo '
'; +include_once __DIR__ . '/nbar_query.php'; +include_once __DIR__ . '/nbar_chart.php'; +fv_nbar_chart($listener_scope, $lid === 0 ? [$start, $end] : [$lid, $start, $end], $family, $host); +echo '
'; +html_end_box(); +include __DIR__ . '/nbar_setup_guide.php'; + + + + +echo ''; +echo ''; +echo ''; +if ($mode === 'conversations') { + include __DIR__ . '/nbar_conversations.php'; + bottom_footer(); + exit; +} +if ($mode === 'connections' && $aid !== '' && !filter_var($nbar_request['internal'] ?? '', FILTER_VALIDATE_IP)) { + echo '

' . html_escape(__('← Back to top applications', 'flowview')) . '

'; +} +$where = $listener_scope . ' AND d.minute>=? AND d.minute' . html_escape(__('← Back to conversations', 'flowview')) . '   ' . fv_nbar_h($internal) . ' ↔ ' . fv_nbar_h($external) . '

'; +} +$fields = 'd.app_id,n.name,SUM(d.bytes) bytes,SUM(d.packets) packets,SUM(d.flows) records,IF(COUNT(d.first_seen)=COUNT(*),MIN(d.first_seen),NULL) first_seen,IF(COUNT(d.last_seen)=COUNT(*),MAX(d.last_seen),NULL) last_seen'; +$group = 'd.app_id,n.name'; +if ($mode === 'connections') { + $fields .= ',INET6_NTOA(d.src) src,INET6_NTOA(d.dst) dst,d.sport,d.dport,d.protocol'; + $group .= ',d.src,d.dst,d.sport,d.dport,d.protocol'; +} +$nbar_source = $mode === 'apps' ? fv_nbar_summary_source($mode === 'apps' && $host === '' ? 'apps' : ($mode === 'apps' ? 'pairs' : 'connections'), $lid === 0 ? $combined_lids : [$lid], $start, $end) : null; +if ($mode === 'apps') { + // Resolve names after reducing raw records to one row per capture/application. + $sql = 'SELECT d.app_id,n.name,SUM(d.bytes) bytes,SUM(d.packets) packets,SUM(d.flows) records,IF(COUNT(d.first_seen)=COUNT(*),MIN(d.first_seen),NULL) first_seen,IF(COUNT(d.last_seen)=COUNT(*),MAX(d.last_seen),NULL) last_seen FROM (SELECT d.listener_id,d.app_id,d.router,SUM(d.bytes) bytes,SUM(d.packets) packets,SUM(d.flows) flows,IF(COUNT(d.first_seen)=COUNT(*),MIN(d.first_seen),NULL) first_seen,IF(COUNT(d.last_seen)=COUNT(*),MAX(d.last_seen),NULL) last_seen FROM ' . $nbar_source . ' d WHERE ' . $where . ' GROUP BY d.listener_id,d.app_id,d.router) d JOIN plugin_flowview_devices l ON l.id=d.listener_id LEFT JOIN plugin_flowview_nbar_names n ON n.router=COALESCE(NULLIF(d.router,SPACE(0)),l.allowfrom) AND n.app_id=d.app_id GROUP BY d.app_id,n.name ORDER BY bytes DESC LIMIT 100'; +} +if ($mode === 'connections') { + require_once __DIR__ . '/nbar_count.php'; + $nbar_count_key = fv_nbar_count_context('', $where, $args, $end, $lid === 0 ? $combined_lids : [$lid], $start); + require_once __DIR__ . '/nbar_connections.php'; + try { + $rows = fv_nbar_connections_cached($lid === 0 ? $combined_lids : [$lid], $start, $end, $where, $args, $page); + } catch (Throwable $e) { + cacti_log('NBAR connection page: ' . $e->getMessage(), false, 'FLOWVIEW'); + echo '

' . fv_nbar_h(__('Unable to load connections. Try a shorter timespan or filter by IP.', 'flowview')) . '

'; + bottom_footer(); + return; + } +} else { + $rows = fv_nbar_query_cached($sql, $args); + if (!is_array($rows)) { + echo '

' . fv_nbar_h(__('Unable to load applications. Please try again.', 'flowview')) . '

'; + bottom_footer(); + return; + } +} +$has_next = $mode === 'connections' && cacti_sizeof($rows) > 100; +if ($has_next) { + $rows = array_slice($rows, 0, 100); +} +$nbar_pagination = ''; +if ($mode === 'connections') { + $page_params = ['mode' => 'connections', 'app' => $aid, 'parent_app' => $parent_app, 'host' => $host, 'internal' => $internal, 'external' => $external]; + $nbar_page_key = hash('sha256', $base . '&' . http_build_query($page_params)); + $nbar_pagination = '
'; + $nbar_page_total = '' . fv_nbar_h(__('Counting total pages…', 'flowview')) . ''; + $nbar_pagination .= '' . $nbar_page_total . '' . fv_nbar_h(__('Up to 100 connections per page, ordered by data volume.', 'flowview')) . ''; + if ($page > 1) { + $nbar_pagination .= '' . __('Previous', 'flowview') . ''; + } + if ($has_next && $page < 1000000) { + $nbar_pagination .= '' . __('Next', 'flowview') . ''; + } + $nbar_pagination .= '
'; + echo $nbar_pagination; +} +if ($mode === 'connections' && $aid !== '') { + $selected_name = $rows[0]['name'] ?? __('Application %s', $aid, 'flowview'); + echo '

' . fv_nbar_h(strtoupper($selected_name)) . ' — Connections

'; +} +echo '
'; +if ($mode === 'connections') { + echo ''; +} +echo ''; +$nbar_row = 0; +foreach ($rows as $r) { + $name = $r['name'] ?? __('Awaiting name (%s)', $r['app_id'], 'flowview'); + echo ''; + + if ($mode === 'connections') { + foreach (['src', 'sport', 'dst', 'dport'] as $f) { + echo ''; + if ($f === 'src' || $f === 'dst') { + echo ''; + } + } + echo ''; + } + + echo '' . fv_nbar_seen_cells($r) . ''; +} +if (!$rows) { + echo ''; +} +echo '
' . html_escape(__('Application', 'flowview')) . '' . html_escape(__('Source IP', 'flowview')) . '' . html_escape(__('Source DNS', 'flowview')) . '' . html_escape(__('Source port', 'flowview')) . '' . html_escape(__('Destination IP', 'flowview')) . '' . html_escape(__('Destination DNS', 'flowview')) . '' . html_escape(__('Destination port', 'flowview')) . '' . html_escape(__('Protocol', 'flowview')) . '' . html_escape(__('Data (GB)', 'flowview')) . '' . html_escape(__('Bandwidth (Avg Mbps)', 'flowview')) . '' . html_escape(__('Packets', 'flowview')) . '' . html_escape(__('Records', 'flowview')) . '' . html_escape(__('First seen', 'flowview')) . '' . html_escape(__('Last seen', 'flowview')) . '
'; + if ($mode === 'connections') { + echo fv_nbar_h($name); + } else { + echo '' . fv_nbar_h($name) . ''; + } + echo '' . fv_nbar_h($r[$f]) . '' . fv_nbar_h(fv_nbar_dns($r[$f])) . '' . fv_nbar_h([6 => 'TCP', 17 => 'UDP', 1 => 'ICMP', 58 => 'ICMPv6'][$r['protocol']] ?? $r['protocol']) . '' . ($r['bytes'] > 0 && $r['bytes'] < 100000 ? '<0.0001' : number_format($r['bytes'] / 1e9, 4)) . '' . ($r['bytes'] > 0 && $r['bytes'] * 8 / $seconds / 1e6 < 0.001 ? '<0.001' : number_format($r['bytes'] * 8 / $seconds / 1e6, 3)) . '' . number_format($r['packets']) . '' . number_format($r['records']) . '
' . html_escape(__('No application records in this view yet. Try a longer period or another traffic view.', 'flowview')) . '
'; +echo $nbar_pagination; +if ($mode === 'connections') { + echo ''; +} + +echo '
' . html_escape(__('Report information', 'flowview')) . '

' . html_escape(__('First seen and Last seen show the earliest and latest FlowView observations within the selected period, at minute precision. They do not identify continuous activity or exact flow start/end times. Unavailable means the retained summary has no complete observation dates.', 'flowview')) . '

' . html_escape(__('Results are ordered by data volume; Connections shows up to 100 per page. Column sorting applies to the displayed page. NBAR history uses the configured retention setting. Daily summaries are reused across report selections; partial days use hourly summaries, and partial or changed hours use raw records. Connections pages are read from shared daily history, with nearby pages briefly cached for navigation. Average Mbps covers the selected period; allow 1–2 minutes for exports. Capture visibility depends on the device exporter and observation point. NAT can hide internal IPv4 addresses on a WAN capture. Select one observation point per traffic path to avoid duplicate totals. Cisco NBAR may classify encrypted traffic as SSL, QUIC or Unknown. History starts when export is enabled.', 'flowview')) . '

'; +echo ''; +bottom_footer(); diff --git a/nbar_seen.php b/nbar_seen.php new file mode 100644 index 0000000..653b62d --- /dev/null +++ b/nbar_seen.php @@ -0,0 +1,220 @@ + 0 ? date('Y-m-d H:i', $stamp) : __('Unavailable', 'flowview'); + $hint = __('FlowView observation within the selected period; minute precision. Time zone: %s', date_default_timezone_get(), 'flowview'); + $html .= '' . fv_nbar_h($text) . ''; + } + return $html; +} +/** + * Merge observation dates while preserving missing-date information. + * + * @param array $total Accumulated dates and missing-date marker. + * @param array $row Exported tuple or observation fields. + * + * @return void + */ +function fv_nbar_seen_merge(array &$total, array $row) { + if (!isset($row['first_seen'],$row['last_seen'])) { + $total['_seen_missing'] = true; + return; + } + $first = (int) $row['first_seen']; + $last = (int) $row['last_seen']; + $total['first_seen'] = $total['first_seen'] === null ? $first : min($total['first_seen'], $first); + $total['last_seen'] = $total['last_seen'] === null ? $last : max($total['last_seen'], $last); +} +/** + * Install observation columns and resumable backfill checkpoints. + * + * @return bool + */ +function fv_nbar_seen_schema() { + foreach (['', 'daily_'] as $prefix) { + foreach (['apps', 'pairs', 'connections'] as $kind) { + $table = 'plugin_flowview_nbar_' . $prefix . $kind; + foreach (['first_seen', 'last_seen'] as $field) { + if (!flowview_db_fetch_row("SHOW COLUMNS FROM $table LIKE '$field'")) { + if (flowview_db_add_column($table, ['name' => $field, 'type' => 'int', 'unsigned' => true, 'NULL' => true]) === false) { + return false; + } + } + } + } + } + $exists = flowview_db_fetch_cell("SHOW TABLES LIKE 'plugin_flowview_nbar_seen_upgrade'"); + if (!$exists) { + if (fv_nbar_schema_table('seen_upgrade') === false) { + return false; + } + } + // Always retry idempotent seeds, including after an interrupted installation. + foreach (['0' => 'hours', '1' => 'days'] as $kind => $table) { + $field = $kind === 0 ? 'hour' : 'day'; + if (flowview_db_execute("INSERT IGNORE INTO plugin_flowview_nbar_seen_upgrade (kind,listener_id,period) SELECT $kind,listener_id,$field FROM plugin_flowview_nbar_$table") === false) { + return false; + } + } + foreach (['phase' => ['name' => 'phase', 'type' => 'tinyint', 'unsigned' => true, 'NULL' => false, 'default' => 0], 'app_cursor' => ['name' => 'app_cursor', 'type' => 'varchar(32)', 'NULL' => false, 'default' => '']] as $field => $definition) { + if (!flowview_db_fetch_row("SHOW COLUMNS FROM plugin_flowview_nbar_seen_upgrade LIKE '$field'") && flowview_db_add_column('plugin_flowview_nbar_seen_upgrade', $definition) === false) { + return false; + } + } + return true; +} +/** + * Backfill dates only where retained counters exactly match source totals. + * + * @param int $budget Worker time budget in seconds. + * + * @return int + */ +function fv_nbar_seen_backfill($budget = 10) { + if ((int) flowview_db_fetch_cell("SELECT GET_LOCK(CONCAT('flowview_nbar_summary:',LEFT(SHA2(DATABASE(),256),16)),0)") !== 1) { + return 0; + } + $daily = false; + $done = 0; + $began = microtime(true); + $old = null; + $isolation = flowview_db_fetch_cell('SELECT @@session.transaction_isolation', '', false); + if (!in_array($isolation, ['READ-UNCOMMITTED', 'READ-COMMITTED', 'REPEATABLE-READ', 'SERIALIZABLE'], true)) { + $isolation = flowview_db_fetch_cell('SELECT @@session.tx_isolation', '', false); + } + try { + $daily = (int) flowview_db_fetch_cell("SELECT GET_LOCK(CONCAT('flowview_nbar_daily:',LEFT(SHA2(DATABASE(),256),16)),0)") === 1; + if (!$daily) { + return 0; + } + if (!in_array($isolation, ['READ-UNCOMMITTED', 'READ-COMMITTED', 'REPEATABLE-READ', 'SERIALIZABLE'], true)) { + return 0; + } + $old = fv_nbar_worker_timeout(20); + if ($old === null || !flowview_db_execute('SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED')) { + return 0; + } + $jobs = flowview_db_fetch_assoc('SELECT kind,listener_id,period,phase,app_cursor FROM plugin_flowview_nbar_seen_upgrade WHERE done=0 ORDER BY kind,period DESC,listener_id LIMIT 100'); + foreach ($jobs as $job) { + if (microtime(true) - $began >= $budget) { + break; + } + $listener = (int) $job['listener_id']; + $period = (int) $job['period']; + $isDay = (int) $job['kind'] === 1; + $end = $period + 3600; + $prefix = ''; + if ($isDay) { + $prefix = 'daily_'; + $end = (int) flowview_db_fetch_cell_prepared('SELECT covered_to FROM plugin_flowview_nbar_days WHERE listener_id=? AND day=?', [$listener, $period]); + } + $ok = true; + $phase = 0; + foreach (['apps' => 'app_id,LENGTH(src) family', 'pairs' => 'app_id,src,dst', 'connections' => 'app_id,src,dst,sport,dport,protocol'] as $kind => $fields) { + if ($isDay && $phase < (int) $job['phase']) { + $phase++; + continue; + } + $group = ($kind === 'apps' ? 'app_id,LENGTH(src)' : $fields) . ',router'; + $keys = array_merge($kind === 'apps' ? ['app_id', 'family'] : explode(',', $fields), ['router']); + $column = flowview_db_fetch_row("SHOW FULL COLUMNS FROM plugin_flowview_nbar_$prefix$kind LIKE 'app_id'"); + $collation = $column['Collation']; + if (!preg_match('/^[a-zA-Z0-9_]+$/D', $collation)) { + throw new RuntimeException('Invalid application collation'); + } + $join = implode(' AND ', array_map(static function ($k) use ($collation) {return $k === 'app_id' ? "s.$k=r.$k COLLATE $collation" : "s.$k=r.$k";}, $keys)); + $source = $isDay ? fv_nbar_hourly_source($kind, [$listener], $period, $end) : '(SELECT *,minute first_seen,minute last_seen FROM plugin_flowview_nbar_data)'; + $dates = 'IF(COUNT(first_seen)=COUNT(*),MIN(first_seen),NULL) first_seen,IF(COUNT(last_seen)=COUNT(*),MAX(last_seen),NULL) last_seen'; + $sql = "UPDATE plugin_flowview_nbar_$prefix$kind s JOIN (SELECT $fields,router,SUM(bytes) bytes,SUM(packets) packets,SUM(flows) flows,$dates FROM $source d WHERE listener_id=$listener AND minute>=$period AND minute<$end GROUP BY $group) r ON $join SET s.first_seen=r.first_seen,s.last_seen=r.last_seen WHERE s.listener_id=$listener AND s.hour=$period AND s.bytes=r.bytes AND s.packets=r.packets AND s.flows=r.flows AND (s.first_seen IS NULL OR s.last_seen IS NULL)"; + $applications = [null]; + if ($isDay && $kind === 'connections') { + $applications = array_column(flowview_db_fetch_assoc_prepared('SELECT DISTINCT app_id FROM plugin_flowview_nbar_daily_apps WHERE listener_id=? AND hour=? AND app_id>? ORDER BY app_id', [$listener, $period, ($job['app_cursor'] ?? '')]), 'app_id'); + } + foreach ($applications as $application) { + if (microtime(true) - $began >= $budget) { + $ok = false; + break; + } + $statement = $sql; + if ($application !== null) { + $value = 'CONVERT(0x' . bin2hex($application) . ' USING utf8mb4) COLLATE ' . $collation; + $statement = str_replace(' GROUP BY ', " AND app_id=$value GROUP BY ", $statement); + $statement .= " AND s.app_id=$value"; + } + if (flowview_db_execute($statement) === false) { + $ok = false; + break; + } + if ($application !== null) { + flowview_db_execute_prepared('UPDATE plugin_flowview_nbar_seen_upgrade SET app_cursor=? WHERE kind=? AND listener_id=? AND period=?', [$application, $job['kind'], $listener, $period]); + } + } + if (!$ok) { + break; + } + $phase++; + if ($isDay) { + flowview_db_execute_prepared('UPDATE plugin_flowview_nbar_seen_upgrade SET phase=? WHERE kind=? AND listener_id=? AND period=?', [$phase, $job['kind'], $listener, $period]); + } + } + if (!$ok) { + break; + } + if (flowview_db_execute_prepared('UPDATE plugin_flowview_nbar_seen_upgrade SET done=1 WHERE kind=? AND listener_id=? AND period=?', [$job['kind'], $listener, $period]) === false) { + break; + } + $done++; + } + } catch (Throwable $e) { + cacti_log('NBAR observation upgrade: ' . $e->getMessage(), false, 'FLOWVIEW'); + } finally { + if ($old !== null) { + flowview_db_execute('SET SESSION max_statement_time=' . $old); + } + if (in_array($isolation, ['READ-UNCOMMITTED', 'READ-COMMITTED', 'REPEATABLE-READ', 'SERIALIZABLE'], true)) { + flowview_db_execute('SET SESSION TRANSACTION ISOLATION LEVEL ' . str_replace('-', ' ', $isolation)); + } + if ($daily) { + flowview_db_fetch_cell("SELECT RELEASE_LOCK(CONCAT('flowview_nbar_daily:',LEFT(SHA2(DATABASE(),256),16)))"); + } + flowview_db_fetch_cell("SELECT RELEASE_LOCK(CONCAT('flowview_nbar_summary:',LEFT(SHA2(DATABASE(),256),16)))"); + } + return $done; +} diff --git a/nbar_setup_guide.php b/nbar_setup_guide.php new file mode 100644 index 0000000..6ad50e1 --- /dev/null +++ b/nbar_setup_guide.php @@ -0,0 +1,98 @@ + +

COLLECTOR_IP LISTENER_PORT EXPORT_SOURCE VlanXX

flow record CACTI_NBAR4
+ match ipv4 tos
+ match ipv4 protocol
+ match ipv4 source address
+ match ipv4 destination address
+ match transport source-port
+ match transport destination-port
+ match interface input
+ match application name
+ collect transport tcp flags
+ collect interface output
+ collect counter bytes long
+ collect counter packets long
+ collect timestamp sys-uptime first
+ collect timestamp sys-uptime last
+!
+flow record CACTI_NBAR6
+ match ipv6 traffic-class
+ match ipv6 flow-label
+ match ipv6 protocol
+ match ipv6 extension map
+ match ipv6 source address
+ match ipv6 destination address
+ match transport source-port
+ match transport destination-port
+ match interface input
+ match flow direction
+ match application name
+ collect transport tcp flags
+ collect interface output
+ collect counter bytes long
+ collect counter packets long
+ collect timestamp sys-uptime first
+ collect timestamp sys-uptime last
+!
+flow exporter CACTI_LAN
+ destination COLLECTOR_IP
+ source EXPORT_SOURCE
+ transport udp LISTENER_PORT
+ template data timeout 60
+ option application-table timeout 60
+!
+flow monitor CACTI_APP4_LAN
+ exporter CACTI_LAN
+ cache timeout active 60
+ record CACTI_NBAR4
+!
+flow monitor CACTI_APP6_LAN
+ exporter CACTI_LAN
+ cache timeout active 60
+ record CACTI_NBAR6
+!
+interface VlanXX
+ ip flow monitor CACTI_APP4_LAN input
+ ip flow monitor CACTI_APP4_LAN output
+ ipv6 flow monitor CACTI_APP6_LAN input
+ ipv6 flow monitor CACTI_APP6_LAN output

VlanXX Vlan1

show ip nbar version
+show ip nbar protocol-pack active
+show flow interface
+show flow exporter CACTI_LAN statistics
+show flow monitor CACTI_APP4_LAN statistics
+show flow monitor CACTI_APP6_LAN statistics
+show flow monitor CACTI_APP6_LAN cache format table
+show platform hardware qfp active datapath utilization
+


Copyright (c) 2004-2026 The Cacti Group.
+'; diff --git a/nbar_summary.php b/nbar_summary.php new file mode 100644 index 0000000..bbdd15a --- /dev/null +++ b/nbar_summary.php @@ -0,0 +1,238 @@ + 'connection_built', 'type' => 'bigint', 'NULL' => true, 'default' => -1]) === false) { + return false; + } + return fv_nbar_daily_schema() && fv_nbar_seen_schema(); +} + +/** + * Write raw records and invalidate affected hours in one transaction. + * + * @param string $sql Trusted SQL statement. + * @param int $listener Capture identifier. + * @param array $hours Affected UTC hour boundaries. + * @param array $args Prepared statement values. + * + * @return bool + */ +function fv_nbar_summary_store($sql, $listener, $hours, $args = []) { + if (flowview_db_execute('START TRANSACTION') === false) { + return false; + } + $ok = flowview_db_execute_prepared($sql, $args) !== false; + if ($ok) { + $values = []; + foreach (array_keys($hours) as $hour) { + $values[] = '(' . (int) $listener . ',' . (int) $hour . ',1,-1)'; + } + $ok = flowview_db_execute('INSERT INTO plugin_flowview_nbar_hours (listener_id,hour,revision,built) VALUES ' . implode(',', $values) . ' ON DUPLICATE KEY UPDATE revision=revision+1') !== false; + } + if (!$ok) { + flowview_db_execute('ROLLBACK'); + return false; + } + return flowview_db_execute('COMMIT') !== false; +} + +/** + * Maintain closed-hour summaries under a database-scoped worker lock. + * + * @param int $budget Worker time budget in seconds. + * + * @return int + */ +function fv_nbar_summary_refresh($budget = 20, $supervised = false) { + $lock = flowview_db_fetch_row("SELECT GET_LOCK(CONCAT('flowview_nbar_summary:',LEFT(SHA2(DATABASE(),256),16)),0) acquired"); + if ((int) ($lock['acquired'] ?? 0) !== 1) { + return 0; + } + $done = 0; + $started = microtime(true); + try { + $cutoff = time() - fv_nbar_retention_days() * 86400; + $closed = (int) (floor((time() - 300) / 3600) * 3600); + $listeners = flowview_db_fetch_assoc('SELECT id FROM plugin_flowview_devices'); + foreach ($listeners as $listener) { + $id = (int) $listener['id']; + $first = flowview_db_fetch_row_prepared('SELECT minute FROM plugin_flowview_nbar_data WHERE listener_id=? ORDER BY minute LIMIT 1', [$id]); + if (!isset($first['minute'])) { + continue; + } + $values = []; + for ($h = (int) (floor(max($cutoff, (int) $first['minute']) / 3600) * 3600); $h < $closed; $h += 3600) { + $values[] = "($id,$h,0,-1)"; + } + if ($values && flowview_db_execute('INSERT IGNORE INTO plugin_flowview_nbar_hours (listener_id,hour,revision,built) VALUES ' . implode(',', $values)) === false) { + return $done; + } + } + $pending = flowview_db_fetch_assoc("SELECT listener_id,hour,revision FROM plugin_flowview_nbar_hours WHERE hour>=" . (int) (ceil($cutoff / 3600) * 3600) . " AND hour<$closed AND (built<0 OR built<>revision OR connection_built<0 OR connection_built<>revision) ORDER BY hour DESC,listener_id LIMIT 100"); + foreach ($pending as $item) { + if (microtime(true) - $started >= max(1, (int) $budget)) { + break; + } + if (fv_nbar_summary_build((int) $item['listener_id'], (int) $item['hour'], $supervised)) { + $done++; + } + } + } finally { + flowview_db_fetch_row("SELECT RELEASE_LOCK(CONCAT('flowview_nbar_summary:',LEFT(SHA2(DATABASE(),256),16))) released"); + } + return $done + fv_nbar_daily_refresh() + fv_nbar_seen_backfill(5); +} + +/** + * Publish an hourly summary only if its raw revision remains unchanged. + * + * @param int $listener Capture identifier. + * @param int $hour UTC hour boundary in Unix seconds. + * + * @return bool + */ +function fv_nbar_summary_build($listener, $hour, $supervised = false) { + // A MySQL build without DML timeouts is allowed only in the supervised child. + $old_timeout = fv_nbar_worker_timeout(20); + if ($old_timeout === null && !$supervised) { + return false; + } + try { + // READ COMMITTED avoids holding raw-record locks during INSERT ... SELECT. + if (flowview_db_execute('SET TRANSACTION ISOLATION LEVEL READ COMMITTED') === false || flowview_db_execute('START TRANSACTION') === false) { + return false; + } + $state = flowview_db_fetch_row_prepared('SELECT revision FROM plugin_flowview_nbar_hours WHERE listener_id=? AND hour=?', [$listener, $hour]); + if (!isset($state['revision'])) { + throw new RuntimeException('Missing summary state'); + } + $where = ' FROM plugin_flowview_nbar_data WHERE listener_id=? AND minute>=? AND minute 'app_id,LENGTH(src)', + 'pairs' => 'app_id,src,dst', + 'connections' => 'app_id,src,dst,sport,dport,protocol' + ]; + foreach ($specs as $table => $fields) { + if (flowview_db_execute_prepared("DELETE FROM plugin_flowview_nbar_$table WHERE listener_id=? AND hour=?", [$listener, $hour]) === false) { + throw new RuntimeException('Summary delete failed'); + } + $sql = "INSERT INTO plugin_flowview_nbar_$table SELECT $listener,$hour,$fields,SUM(bytes),SUM(packets),SUM(flows),MIN(minute),MAX(minute),router$where GROUP BY $fields,router"; + if (flowview_db_execute_prepared($sql, $args) === false) { + throw new RuntimeException('Summary aggregate failed'); + } + } + $latest = flowview_db_fetch_row_prepared('SELECT revision FROM plugin_flowview_nbar_hours WHERE listener_id=? AND hour=?', [$listener, $hour]); + if (!isset($latest['revision']) || (string) $latest['revision'] !== (string) $state['revision']) { + throw new RuntimeException('Hour changed during summary build'); + } + if (flowview_db_execute_prepared('UPDATE plugin_flowview_nbar_hours SET built=?,connection_built=? WHERE listener_id=? AND hour=?', [$state['revision'], $state['revision'], $listener, $hour]) === false) { + throw new RuntimeException('Summary publish failed'); + } + if (flowview_db_execute('COMMIT') === false) { + throw new RuntimeException('Summary commit failed'); + } + return true; + } catch (Throwable $e) { + flowview_db_execute('ROLLBACK'); + return false; + } finally { + if ($old_timeout !== null) { + flowview_db_execute('SET SESSION max_statement_time=' . $old_timeout); + } + } +} + +/** + * Choose mutually exclusive ready-summary or raw branches for each block. + * + * @param string $kind Allowlisted summary type. + * @param array $listeners Selected capture identifiers. + * @param int $start Inclusive range start in Unix seconds. + * @param int $end Exclusive range end in Unix seconds. + * @param int|null $now Current Unix time, or null to read the clock. + * + * @return string + */ +function fv_nbar_hourly_source($kind, $listeners, $start, $end, $now = null) { + if (!in_array($kind, ['apps', 'pairs', 'connections'], true)) { + throw new InvalidArgumentException('Unknown summary kind'); + } + $listeners = array_values(array_unique(array_filter(array_map('intval', $listeners), static function ($id) {return $id > 0;}))); + if (!$listeners) { + return '(SELECT listener_id,minute,app_id,src,dst,sport,dport,protocol,bytes,packets,flows,minute first_seen,minute last_seen,router FROM plugin_flowview_nbar_data)'; + } + $start = (int) $start; + $end = (int) $end; + $full_start = (int) (ceil($start / 3600) * 3600); + $full_end = min((int) (floor($end / 3600) * 3600), (int) (floor((($now ?? time()) - 300) / 3600) * 3600)); + if ($full_end <= $full_start) { + return '(SELECT listener_id,minute,app_id,src,dst,sport,dport,protocol,bytes,packets,flows,minute first_seen,minute last_seen,router FROM plugin_flowview_nbar_data)'; + } + $built_column = $kind === 'connections' ? 'connection_built' : 'built'; + $parts = []; + foreach ($listeners as $listener) { + $raw = 'listener_id,minute,app_id,src,dst,sport,dport,protocol,bytes,packets,flows,minute first_seen,minute last_seen,router'; + if ($start < $full_start) { + $parts[] = "SELECT $raw FROM plugin_flowview_nbar_data WHERE listener_id=$listener AND minute>=$start AND minute<$full_start"; + } + if ($full_end < $end) { + $parts[] = "SELECT $raw FROM plugin_flowview_nbar_data WHERE listener_id=$listener AND minute>=$full_end AND minute<$end"; + } + for ($h = $full_start; $h < $full_end; $h = $to) { + $to = min($h + 21600, $full_end); + $count = (int) (($to - $h) / 3600); + $ready = "(SELECT COUNT(*) FROM plugin_flowview_nbar_hours WHERE listener_id=$listener AND hour>=$h AND hour<$to AND $built_column>=0 AND $built_column=revision)=$count"; + if ($kind === 'apps') { + $fields = "listener_id,hour minute,app_id,REPEAT(CHAR(0),family) src,REPEAT(CHAR(0),family) dst,0 sport,0 dport,0 protocol,bytes,packets,flows,first_seen,last_seen,router"; + } elseif ($kind === 'pairs') { + $fields = 'listener_id,hour minute,app_id,src,dst,0 sport,0 dport,0 protocol,bytes,packets,flows,first_seen,last_seen,router'; + } else { + $fields = 'listener_id,hour minute,app_id,src,dst,sport,dport,protocol,bytes,packets,flows,first_seen,last_seen,router'; + } + $parts[] = "SELECT $fields FROM plugin_flowview_nbar_$kind WHERE listener_id=$listener AND hour>=$h AND hour<$to AND ($ready)"; + $parts[] = "SELECT $raw FROM plugin_flowview_nbar_data WHERE listener_id=$listener AND minute>=$h AND minute<$to AND NOT ($ready)"; + } + } + return '(' . implode(' UNION ALL ', $parts) . ')'; +} diff --git a/nbar_timespan.php b/nbar_timespan.php new file mode 100644 index 0000000..85ebbc6 --- /dev/null +++ b/nbar_timespan.php @@ -0,0 +1,92 @@ + $label) { + $span = []; + get_timespan($span, $now, $id, $weekday); + $from = strtotime($span['current_value_date1']); + $to = strtotime($span['current_value_date2']); + if ($from >= $cutoff && $to > $from && $to <= $now) { + // The exact retention option below replaces duplicate rolling periods. + if ($from === $cutoff && $to === $now) { + continue; + } + $options[$id] = $label; + } + } + $options[1000] = $days === 1 ? __('Last Day', 'flowview') : __('Last %s Days', $days, 'flowview'); + return $options; +} +/** + * Clamp a requested range to retained history and the current time. + * + * @param int $start Inclusive range start in Unix seconds. + * @param int $end Exclusive range end in Unix seconds. + * @param int $now Current Unix time, or null to read the clock. + * @param int $days Configured retention in days. + * + * @return array + */ +function fv_nbar_bound_dates($start, $end, $now, $days) { + $cutoff = $now - $days * 86400; + $from = max($cutoff, $start); + $to = min($now, $end); + if ($to <= $from) { + return [$now - 1800, $now, true]; + } + return [$from, $to, $from !== $start || $to !== $end]; +} + +/** Handle NBAR timespans without mutating legacy Flow Reports session state. */ +function fv_nbar_timespan_request(): bool { + if (get_nfilter_request_var('report') !== 'nbar') { + return false; + } + header('Content-Type: application/json'); + if ((int) get_filter_request_var('predefined_timespan') === 1000) { + $end = (int) (floor(time() / 60) * 60); + $start = $end - fv_nbar_retention_days() * 86400; + print json_encode(['current_value_date1' => date('Y-m-d H:i', $start), 'current_value_date2' => date('Y-m-d H:i', $end), 'begin_now' => $start, 'end_now' => $end]); + } else { + flowview_gettimespan(); + } + return true; +} diff --git a/phpunit.xml b/phpunit.xml index 2ec8407..03f0a9b 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -39,6 +39,24 @@ Net/DNS2/Updater.php includes/functions.php includes/database.php + includes/nbar_database.php + nbar_chart.php + nbar_collector.php + nbar_common.php + nbar_connections.php + nbar_conversations.php + nbar_count.php + nbar_daily.php + nbar_dns.php + nbar_feed_status.php + nbar_preferences.php + nbar_query.php + nbar_report.php + nbar_seen.php + nbar_setup_guide.php + nbar_summary.php + nbar_timespan.php + flowview_sections.php diff --git a/setup.php b/setup.php index 8726c0f..9a78d93 100644 --- a/setup.php +++ b/setup.php @@ -39,6 +39,21 @@ function plugin_flowview_csp_nonce(): string { return ''; } +/** + * Escape an HTML attribute on supported Cacti 1.2 and newer installations. + * + * @param mixed $value Attribute value. + * + * @return string Escaped attribute value. + */ +function plugin_flowview_escape_attr($value): string { + if (function_exists('html_escape_attr')) { + return html_escape_attr($value); + } + + return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + /** * @return bool */ @@ -146,7 +161,7 @@ function plugin_flowview_check_upgrade(bool $force = false): void { FROM plugin_config WHERE directory = ?', array('flowview')); - if ($current != $old || $force) { + if ($current != $old || $force || (int) read_config_option('flowview_nbar_schema_version') < 2) { $php_binary = read_config_option('path_php_binary'); exec_background($php_binary, '-q ' .$config['base_path'] . '/plugins/flowview/flowview_upgrade.php'); @@ -229,6 +244,8 @@ function flowview_draw_navigation_text($nav) { 'level' => '0' ); + $nav['flowview.php:applications'] = $nav['flowview.php:']; + $nav['flowview.php:view'] = array( 'title' => __('Dashboard', 'flowview'), 'mapping' => 'flowview.php:', @@ -620,6 +637,12 @@ function flowview_config_settings(): void { ), 'default' => 30 ), + 'flowview_nbar_retention_days' => array( + 'friendly_name' => __('Cisco Apps (NBAR) History Retention', 'flowview'), + 'description' => __('Days of application history to retain. Reducing this removes older history during scheduled cleanup; increasing it cannot restore expired data.', 'flowview'), + 'method' => 'drop_array', 'default' => '7', + 'array' => array(1 => __('1 Day', 'flowview'), 3 => __('3 Days', 'flowview'), 7 => __('7 Days', 'flowview'), 14 => __('14 Days', 'flowview'), 30 => __('30 Days', 'flowview')) + ), 'flowview_partition' => array( 'friendly_name' => __('Database Partitioning Scheme', 'flowview'), 'description' => __('Depending on the number of flows per minute, you may require more tables per day.', 'flowview'), @@ -771,6 +794,10 @@ function flowview_poller_bottom(): void { include_once($config['base_path'] . '/lib/poller.php'); flowview_connect(); + require_once __DIR__ . '/nbar_common.php'; + if ((int) read_config_option('flowview_nbar_schema_version') >= 2) { + fv_nbar_launch_maintenance(); + } $retention_days = read_config_option('flowview_retention'); @@ -1069,6 +1096,11 @@ function flowview_setup_table(): void { global $config, $settings, $flowviewdb_default; flowview_connect(); + require_once __DIR__ . '/nbar_common.php'; + if (!fv_nbar_schema_install()) { + throw new RuntimeException('Unable to install NBAR history schema'); + } + set_config_option('flowview_nbar_schema_version', '2'); flowview_db_execute("CREATE TABLE IF NOT EXISTS `plugin_flowview_dnscache` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, diff --git a/tests/Unit/CspNonceTest.php b/tests/Unit/CspNonceTest.php index d536c9a..d9a7b7c 100644 --- a/tests/Unit/CspNonceTest.php +++ b/tests/Unit/CspNonceTest.php @@ -64,7 +64,7 @@ class CactiSecureHeaders { public static function getNonceAttribute(): string { - return ' nonce="pest-controlled-double"'; + return 'nonce="pest-controlled-double"'; } } @@ -86,8 +86,16 @@ public static function getNonceAttribute(): string { preg_match('/<<<(.*)>>>/s', (string) $output, $matches); expect($matches)->toHaveKey(1); - expect($matches[1])->toBe(' nonce="pest-controlled-double"'); + expect($matches[1])->toBe('nonce="pest-controlled-double"'); } finally { @unlink($tmp); } }); + + +test('inline script tags separate the nonce attribute from the tag name', function () { + foreach (glob(dirname(__DIR__, 2) . '/*.php') as $file) { + $source = file_get_contents($file); + expect(preg_match('/[\'"]toBe(0, basename($file)); + } +}); diff --git a/tests/Unit/NbarBackfillTest.php b/tests/Unit/NbarBackfillTest.php new file mode 100644 index 0000000..280191d --- /dev/null +++ b/tests/Unit/NbarBackfillTest.php @@ -0,0 +1,88 @@ + 0, 'listener_id' => 3, 'period' => 1800000000, 'phase' => 0]]); + foreach (range(1, 3) as $i) { + plugin_test_queue_db_result('db_fetch_row', ['Collation' => 'utf8mb4_unicode_ci']); + } + expect(fv_nbar_seen_backfill())->toBe(1); + $calls = $GLOBALS['__test_db_calls']; + $sql = implode(' ', array_column($calls, 'sql')); + expect($sql)->toContain('s.bytes=r.bytes AND s.packets=r.packets AND s.flows=r.flows', 'SET done=1', 'max_statement_time=7.5', 'REPEATABLE READ'); + $updates = array_filter($calls, fn ($call) => str_starts_with($call['sql'], 'UPDATE plugin_flowview_nbar_') && $call['fn'] === 'db_execute'); + expect($updates)->toHaveCount(3); + foreach ($updates as $call) { + expect($call['sql'])->toContain('minute>=1800000000 AND minute<1800003600'); + expect($call['conn'])->toBe('backfill-test'); + } +}); + +it('resumes daily connection dates from the saved application cursor', function () { + plugin_test_queue_db_result('db_fetch_assoc', [['kind' => 1, 'listener_id' => 2, 'period' => 1800000000, 'phase' => 2, 'app_cursor' => 'previous']]); + plugin_test_queue_db_result('db_fetch_cell_prepared', 1800086400); + plugin_test_queue_db_result('db_fetch_row', ['Collation' => 'utf8mb4_unicode_ci']); + plugin_test_queue_db_result('db_fetch_assoc_prepared', [['app_id' => "app'quoted"], ['app_id' => 'second']]); + expect(fv_nbar_seen_backfill())->toBe(1); + $calls = $GLOBALS['__test_db_calls']; + $cursor = array_values(array_filter($calls, fn ($call) => str_contains($call['sql'], 'SET app_cursor=?'))); + expect($cursor)->toHaveCount(2); + expect($cursor[0]['params'])->toBe(["app'quoted", 1, 2, 1800000000]); + $sql = implode(' ', array_column($calls, 'sql')); + expect($sql)->toContain('UPDATE plugin_flowview_nbar_daily_connections', 'CONVERT(0x' . bin2hex("app'quoted"), 'SET phase=?', 'SET done=1'); + expect($sql)->not->toContain("app'quoted", 'UPDATE plugin_flowview_nbar_daily_apps'); +}); + +it('leaves failed date jobs pending and restores database session settings', function ($failure) { + plugin_test_queue_db_result('db_fetch_assoc', [['kind' => 0, 'listener_id' => 1, 'period' => 1800000000, 'phase' => 0]]); + plugin_test_queue_db_result('db_fetch_row', ['Collation' => $failure === 'collation' ? 'unsafe;SQL' : 'utf8mb4_unicode_ci']); + if ($failure === 'write') { + foreach ([true, true, false] as $value) { + plugin_test_queue_db_result('db_execute', $value); + } + } + expect(fv_nbar_seen_backfill())->toBe(0); + $sql = implode(' ', array_column($GLOBALS['__test_db_calls'], 'sql')); + expect($sql)->not->toContain('SET done=1', 'unsafe;SQL'); + expect($sql)->toContain('max_statement_time=7.5', 'REPEATABLE READ', 'RELEASE_LOCK'); +})->with(['collation', 'write']); + +it('does not begin a job when the backfill budget is exhausted', function () { + plugin_test_queue_db_result('db_fetch_assoc', [['kind' => 0, 'listener_id' => 1, 'period' => 1800000000, 'phase' => 0]]); + expect(fv_nbar_seen_backfill(0))->toBe(0); + $sql = implode(' ', array_column($GLOBALS['__test_db_calls'], 'sql')); + expect($sql)->not->toContain('SHOW FULL COLUMNS', 'SET done=1'); + expect($sql)->toContain('RELEASE_LOCK'); +}); + +it('keeps a completed date job pending when its checkpoint cannot be saved', function () { + plugin_test_queue_db_result('db_fetch_assoc', [['kind' => 0, 'listener_id' => 1, 'period' => 1800000000, 'phase' => 0]]); + foreach (range(1, 3) as $i) { + plugin_test_queue_db_result('db_fetch_row', ['Collation' => 'utf8mb4_unicode_ci']); + } + plugin_test_queue_db_result('db_execute_prepared', false); + expect(fv_nbar_seen_backfill())->toBe(0); + expect(implode(' ', array_column($GLOBALS['__test_db_calls'], 'sql')))->toContain('RELEASE_LOCK', 'SET done=1'); +}); + +it('leaves a job pending if its application work exhausts the budget', function () { + plugin_test_queue_db_result('db_fetch_assoc', [['kind' => 0, 'listener_id' => 1, 'period' => 1800000000, 'phase' => 0]]); + plugin_test_queue_db_result('db_fetch_row', static function () { usleep(1100000); return ['Collation' => 'utf8mb4_unicode_ci']; }); + expect(fv_nbar_seen_backfill(1))->toBe(0); + $sql = implode(' ', array_column($GLOBALS['__test_db_calls'], 'sql')); + expect($sql)->not->toContain('SET done=1'); + expect($sql)->toContain('RELEASE_LOCK'); +}); diff --git a/tests/Unit/NbarBehaviorTest.php b/tests/Unit/NbarBehaviorTest.php new file mode 100644 index 0000000..306e633 --- /dev/null +++ b/tests/Unit/NbarBehaviorTest.php @@ -0,0 +1,168 @@ +toBe('18446744073709551714'); + expect(fv_nbar_decimal_add('000', '0'))->toBe('0'); + expect(fv_nbar_decimal_compare('099', '100'))->toBeLessThan(0); + expect(fv_nbar_decimal_compare('000123', '123'))->toBe(0); +}); + +it('clips requested dates to retention without changing a valid selection', function () { + $now = 1800000000; + expect(fv_nbar_bound_dates($now - 7200, $now - 60, $now, 7))->toBe([$now - 7200, $now - 60, false]); + expect(fv_nbar_bound_dates($now - 9 * 86400, $now + 60, $now, 7))->toBe([$now - 7 * 86400, $now, true]); + expect(fv_nbar_bound_dates($now - 9 * 86400, $now - 8 * 86400, $now, 7))->toBe([$now - 1800, $now, true]); +}); + +it('uses local calendar days across daylight saving changes', function () { + $old = date_default_timezone_get(); + try { + date_default_timezone_set('Australia/Adelaide'); + [$from, $to] = fv_nbar_daily_bounds(strtotime('2026-10-04 12:00')); + expect(date('Y-m-d H:i', $from))->toBe('2026-10-04 00:00'); + expect(date('Y-m-d H:i', $to))->toBe('2026-10-05 00:00'); + expect($to - $from)->toBe(23 * 3600); + } finally { + date_default_timezone_set($old); + } +}); + +it('validates internal IPv4 and IPv6 prefixes and rejects invalid masks', function () { + expect(fv_nbar_cidrs("192.0.2.0/24\n2001:db8::/64"))->toBe(['192.0.2.0/24', '2001:db8::/64']); + expect(fv_nbar_cidrs('192.0.2.0/33'))->toBeFalse(); + expect(fv_nbar_cidrs('2001:db8::/129'))->toBeFalse(); + expect(fv_nbar_cidrs('example.com/24'))->toBeFalse(); + expect(fv_nbar_cidrs(''))->toBe([]); +}); + +it('decodes template-defined IPv4 and IPv6 application records using the dedicated connection', function () { + $GLOBALS['listener_id'] = 3; + foreach ([4, 16] as $length) { + $src = inet_pton($length === 4 ? '192.0.2.10' : '2001:db8::10'); + $dst = inet_pton($length === 4 ? '198.51.100.20' : '2001:db8:1::20'); + $fields = [95 => 4, $length === 4 ? 8 : 27 => $length, $length === 4 ? 12 : 28 => $length, 7 => 2, 11 => 2, 4 => 1, 1 => 4, 2 => 4]; + $definitions = ''; + foreach ($fields as $id => $size) { + $definitions .= pack('nn', $id, $size); + } + $template = pack('nnnn', 0, 8 + strlen($definitions), 400 + $length, count($fields)) . $definitions; + $record = hex2bin('0d000001') . $src . $dst . pack('nnCNN', 50000, 443, 6, 123456, 120); + $data = pack('nn', 400 + $length, 4 + strlen($record)) . $record; + $packet = pack('nnNNNN', 9, 2, 100000, time(), 1, 100 + $length) . $template . $data; + fv_nbar_packet($packet, '192.0.2.1'); + $calls = array_values(array_filter($GLOBALS['__test_db_calls'], fn ($c) => $c['fn'] === 'db_execute_prepared' && str_contains($c['sql'], 'INSERT INTO plugin_flowview_nbar_data'))); + $call = end($calls); + expect($call['conn'])->toBe('nbar-test-connection'); + expect($call['params'][2])->toBe('0d000001'); + expect($call['params'][3])->toBe(bin2hex($src)); + expect($call['params'][4])->toBe(bin2hex($dst)); + expect(array_slice($call['params'], 5))->toBe([50000, 443, 6, 123456, 120, '192.0.2.1']); + } +}); + +it('ignores malformed or unsupported packets without writing data', function () { + fv_nbar_packet('short', '192.0.2.1'); + fv_nbar_packet(pack('nnNNNN', 10, 0, 0, time(), 0, 0), '192.0.2.1'); + fv_nbar_packet(pack('nnNNNN', 9, 1, 0, time(), 0, 0) . pack('nn', 0, 65535), '192.0.2.1'); + expect($GLOBALS['__test_db_calls'])->toBe([]); +}); + + +it('saves two or three combined captures without depending on a success message', function () { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_REQUEST = ['prefs_apply' => '1', 'combine_apply' => '1', 'local_prefixes' => '192.0.2.0/24', 'site_listeners' => ['1', '2']]; + $devices = [['id' => 1], ['id' => 2], ['id' => 3]]; + $saved = fv_nbar_preferences_init($devices); + expect($GLOBALS['fv_nbar_preferences_saved'])->toBeTrue(); + expect($saved['listeners'])->toBe([1, 2]); + expect($GLOBALS['fv_nbar_preferences_message'])->toBe(''); + $_REQUEST['site_listeners'] = ['1', '2', '3']; + expect(fv_nbar_preferences_init($devices)['listeners'])->toBe([1, 2, 3]); + $_SERVER['REQUEST_METHOD'] = 'GET'; +}); + +it('rejects a single combined capture but permits saving networks with none selected', function () { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_REQUEST = ['prefs_apply' => '1', 'combine_apply' => '1', 'local_prefixes' => '2001:db8::/64', 'site_listeners' => ['1']]; + $devices = [['id' => 1], ['id' => 2]]; + fv_nbar_preferences_init($devices); + expect($GLOBALS['fv_nbar_preferences_saved'])->toBeFalse(); + $_REQUEST['site_listeners'] = []; + $saved = fv_nbar_preferences_init($devices); + expect($GLOBALS['fv_nbar_preferences_saved'])->toBeTrue(); + expect($saved['listeners'])->toBe([]); + expect($saved['prefixes'])->toBe(['2001:db8::/64']); + $_SERVER['REQUEST_METHOD'] = 'GET'; +}); + +it('rolls back a failed raw batch before publishing a summary revision', function () { + plugin_test_queue_db_result('db_execute_prepared', false); + expect(fv_nbar_summary_store('INSERT fixture VALUES (?)', 1, [1800000000 => true], [123]))->toBeFalse(); + $sql = array_column($GLOBALS['__test_db_calls'], 'sql'); + expect(end($sql))->toBe('ROLLBACK'); + expect(implode(' ', $sql))->not->toContain('revision=revision+1'); +}); + + +it('escapes quotes and markup in attributes on Cacti 1.2', function () { + expect(plugin_flowview_escape_attr('"&'))->toBe('"<host>&'); +}); + + +it('decodes unsigned 64-bit exporter counters without rounding', function () { + expect(fv_nbar_uint(hex2bin('7fffffffffffffff')))->toBe(PHP_INT_MAX); + expect(fv_nbar_uint(hex2bin('8000000000000000')))->toBe('9223372036854775808'); + expect(fv_nbar_uint(hex2bin('ffffffffffffffff')))->toBe('18446744073709551615'); + expect(fv_nbar_uint(hex2bin('00000000000000ff')))->toBe(255); +}); + + +it('passes full-width byte counters to the database as exact decimal strings', function () { + $GLOBALS['listener_id'] = 3; + $fields = [95 => 4, 8 => 4, 12 => 4, 1 => 8]; + $definitions = ''; + foreach ($fields as $id => $size) { + $definitions .= pack('nn', $id, $size); + } + $template = pack('nnnn', 0, 8 + strlen($definitions), 777, count($fields)) . $definitions; + $record = hex2bin('0d000001') . inet_pton('192.0.2.10') . inet_pton('198.51.100.20') . hex2bin('ffffffffffffffff'); + $data = pack('nn', 777, 4 + strlen($record)) . $record; + fv_nbar_packet(pack('nnNNNN', 9, 2, 100000, time(), 1, 777) . $template . $data, '192.0.2.1'); + $calls = array_values(array_filter($GLOBALS['__test_db_calls'], fn ($c) => $c['fn'] === 'db_execute_prepared' && str_contains($c['sql'], 'INSERT INTO plugin_flowview_nbar_data'))); + expect($calls)->toHaveCount(1); + expect($calls[0]['params'][8])->toBe('18446744073709551615'); +}); + + +it('uses a single grouped fallback before probing a sparse history across every segment', function () { + foreach (range(1, 3) as $unused) { + plugin_test_queue_db_result('db_fetch_assoc_prepared', []); + } + for ($segment = 0; $segment < 4; $segment++) { + $rows = []; + for ($i = 0; $i < 257; $i++) { + $rows[] = ['app_id' => '0d000001', 'src_hex' => 'C000020A', 'dst_hex' => strtoupper(bin2hex(inet_pton('198.18.0.' . ($segment + 1)))), 'sport' => $i, 'dport' => 443, 'protocol' => 6, 'bytes' => '100', 'packets' => '1', 'records' => '1', 'first_seen' => 1800000000, 'last_seen' => 1800000000]; + } + plugin_test_queue_db_result('db_fetch_assoc_prepared', $rows); + } + plugin_test_queue_db_result('db_fetch_assoc_prepared', []); + expect(fv_nbar_connections_direct([1], 1799971200, 1799985600, '1=1', [], 1))->toBe([]); + $calls = array_values(array_filter($GLOBALS['__test_db_calls'], fn ($c) => $c['fn'] === 'db_fetch_assoc_prepared')); + expect($calls)->toHaveCount(8); + expect(end($calls)['sql'])->toContain('UNION ALL'); +}); diff --git a/tests/Unit/NbarBoundaryTest.php b/tests/Unit/NbarBoundaryTest.php new file mode 100644 index 0000000..63115f4 --- /dev/null +++ b/tests/Unit/NbarBoundaryTest.php @@ -0,0 +1,71 @@ + $day, 'covered_to' => $day + 86400, 'timezone' => date_default_timezone_get(), 'revision' => '42']]); + $sql = fv_nbar_summary_source($kind, [1], $day - 1800, $day + 88200, time()); + expect($sql)->toContain('plugin_flowview_nbar_daily_' . $kind, 'fallback_before', 'fallback_after', 'fallback_day', 'NOT (EXISTS', 'revision=42'); + expect($sql)->toContain('minute>=' . ($day - 1800), 'minute<' . ($day + 88200)); + if ($kind === 'apps') { + expect($sql)->toContain('REPEAT(CHAR(0),family)'); + } elseif ($kind === 'pairs') { + expect($sql)->toContain('0 sport,0 dport,0 protocol'); + } else { + expect($sql)->toContain('src,dst,sport,dport,protocol'); + } +})->with(['apps', 'pairs', 'connections']); + +it('ignores incompatible or overlapping daily checkpoints', function () { + $day = strtotime('-2 days midnight'); + plugin_test_queue_db_result('db_fetch_assoc_prepared', [ + ['day' => $day, 'covered_to' => $day + 86400, 'timezone' => 'Other/Zone', 'revision' => '1'], + ['day' => $day - 86400, 'covered_to' => $day, 'timezone' => date_default_timezone_get(), 'revision' => '1'], + ['day' => $day, 'covered_to' => time() + 3600, 'timezone' => date_default_timezone_get(), 'revision' => '1'] + ]); + $sql = fv_nbar_summary_source('apps', [1], $day, $day + 86400, time()); + expect($sql)->not->toContain('plugin_flowview_nbar_daily_apps'); + expect($sql)->toContain('fallback_after', 'minute>=' . $day); +}); + +it('uses a raw source when no capture scope exists', function () { + expect(fv_nbar_summary_source('apps', [], 0, 3600))->toContain('plugin_flowview_nbar_data', 'minute first_seen'); + expect(fv_nbar_hourly_source('apps', [], 0, 3600))->toContain('plugin_flowview_nbar_data', 'minute last_seen'); +}); + +it('builds exact partial-byte internal-prefix predicates for both address families', function () { + $GLOBALS['fv_nbar_preferences'] = ['prefixes' => ['192.0.2.128/25', '2001:db8::/65']]; + $sql = fv_nbar_local_sql('d.src'); + expect($sql)->toContain('LENGTH(d.src)=4', 'LENGTH(d.src)=16', '(ORD(SUBSTRING(d.src,4,1)) & 128)=128', '(ORD(SUBSTRING(d.src,9,1)) & 128)=0'); + expect(fn () => fv_nbar_local_sql('d.bytes'))->toThrow(InvalidArgumentException::class); +}); + +it('rejects oversized network lists and invalid network submissions without saving', function () { + $prefixes = implode("\n", array_map(fn ($i) => '192.0.2.' . $i . '/32', range(1, 33))); + expect(fv_nbar_cidrs($prefixes))->toBeFalse(); + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_REQUEST = ['prefs_apply' => '1', 'local_prefixes' => 'not-a-network', 'site_listeners' => []]; + fv_nbar_preferences_init([]); + expect($GLOBALS['fv_nbar_preferences_saved'])->toBeFalse(); + expect($GLOBALS['fv_nbar_preferences_message'])->toContain('Enter valid IPv4/IPv6 networks'); +}); + +it('saves only permitted display options from settings submissions', function () { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_REQUEST = ['prefs_apply' => '1', 'local_prefixes' => '', 'site_listeners' => [], 'data_unit' => 'Auto', 'rate_unit' => 'Kbps', 'dns' => '0']; + $saved = fv_nbar_preferences_init([]); + expect($saved['data_unit'])->toBe('Auto'); + expect($saved['rate_unit'])->toBe('Kbps'); + expect($saved['dns'])->toBe('0'); + expect($GLOBALS['fv_nbar_preferences_saved'])->toBeTrue(); +}); diff --git a/tests/Unit/NbarConnectionPagingTest.php b/tests/Unit/NbarConnectionPagingTest.php new file mode 100644 index 0000000..dcada0f --- /dev/null +++ b/tests/Unit/NbarConnectionPagingTest.php @@ -0,0 +1,154 @@ +start = (int) floor((time() - 86400) / 3600) * 3600; + $this->tuple = ['app_id' => '0d000001', 'src_hex' => bin2hex(inet_pton('2001:db8::10')), 'dst_hex' => bin2hex(inet_pton('2001:db8::20')), 'sport' => 1000, 'dport' => 443, 'protocol' => 6, 'bytes' => '9007199254740993', 'packets' => '5', 'records' => '1', 'first_seen' => $this->start, 'last_seen' => $this->start + 60]; + plugin_test_queue_db_result('db_fetch_assoc_prepared', []); + plugin_test_queue_db_result('db_fetch_assoc_prepared', [['hour' => $this->start], ['hour' => $this->start + 3600]]); + plugin_test_queue_db_result('db_fetch_assoc_prepared', [['listener_id' => 1, 'app_id' => '0d000001', 'name_id' => 1, 'name' => 'youtube']]); +}); + +it('totals every candidate across all segments without rounding large counters', function () { + $a = $this->tuple; + $b = array_replace($a, ['sport' => 2000, 'bytes' => '10']); + $late = array_replace($a, ['bytes' => '2', 'first_seen' => $this->start + 3600, 'last_seen' => $this->start + 3660]); + foreach ([[$a, $b], [$late], [$a, $b], [$late]] as $rows) { + plugin_test_queue_db_result('db_fetch_assoc_prepared', $rows); + } + $rows = fv_nbar_connections_direct([1], $this->start, $this->start + 7200, 'd.app_id=?', ['0d000001'], 1); + expect($rows)->toHaveCount(2); + expect($rows[0]['bytes'])->toBe('9007199254740995'); + expect($rows[0]['packets'])->toBe('10'); + expect($rows[0]['records'])->toBe('2'); + expect($rows[0]['name'])->toBe('youtube'); + expect($rows[0]['src'])->toBe('2001:db8::10'); + expect($rows[0]['dst'])->toBe('2001:db8::20'); + expect($rows[0]['first_seen'])->toBe($this->start); + expect($rows[0]['last_seen'])->toBe($this->start + 3660); + expect($rows[0])->not->toHaveKey('src_hex')->not->toHaveKey('_key'); + $sql = array_column($GLOBALS['__test_db_calls'], 'sql'); + expect(end($sql))->toBe('COMMIT'); + $lookups = array_values(array_filter($GLOBALS['__test_db_calls'], fn ($call) => str_contains($call['sql'], 'UNHEX(?)'))); + expect($lookups)->toHaveCount(2); + expect($lookups[0]['params'][0])->toBe('0d000001'); +}); + +it('uses deterministic tuple ordering for equal-volume connections and preserves missing dates', function () { + $a = array_replace($this->tuple, ['bytes' => '10']); + $b = array_replace($a, ['sport' => 999, 'first_seen' => null, 'last_seen' => null]); + foreach ([[$a, $b], [], [$a, $b], []] as $rows) { + plugin_test_queue_db_result('db_fetch_assoc_prepared', $rows); + } + $rows = fv_nbar_connections_direct([1], $this->start, $this->start + 7200, '1=1', [], 1); + expect(array_column($rows, 'sport'))->toBe([999, 1000]); + expect($rows[0]['first_seen'])->toBeNull(); + expect($rows[0]['last_seen'])->toBeNull(); +}); + +it('rolls back if segment reads or candidate total lookups fail', function ($stage) { + if ($stage === 'candidate') { + plugin_test_queue_db_result('db_fetch_assoc_prepared', false); + } else { + plugin_test_queue_db_result('db_fetch_assoc_prepared', [$this->tuple]); + plugin_test_queue_db_result('db_fetch_assoc_prepared', []); + plugin_test_queue_db_result('db_fetch_assoc_prepared', false); + } + expect(fn () => fv_nbar_connections_direct([1], $this->start, $this->start + 7200, '1=1', [], 1))->toThrow(RuntimeException::class); + $sql = array_column($GLOBALS['__test_db_calls'], 'sql'); + expect(end($sql))->toBe('ROLLBACK'); +})->with(['candidate', 'lookup']); + +it('rejects invalid capture selections before opening a snapshot', function ($captures) { + expect(fn () => fv_nbar_connections_direct($captures, $this->start, $this->start + 7200, '1=1', [], 1))->toThrow(InvalidArgumentException::class); + expect($GLOBALS['__test_db_calls'])->toBe([]); +})->with([[[]], [[1, 2, 3, 4]]]); + +it('uses a complete daily checkpoint between raw partial-day boundaries', function () { + plugin_test_reset(); + $day = strtotime('-2 days midnight'); + plugin_test_queue_db_result('db_fetch_assoc_prepared', [['day' => $day, 'covered_to' => $day + 86400, 'timezone' => date_default_timezone_get(), 'revision' => '10']]); + plugin_test_queue_db_result('db_fetch_row_prepared', ['n' => 24, 'ready' => 24, 'rev' => '10']); + plugin_test_queue_db_result('db_fetch_assoc_prepared', []); + plugin_test_queue_db_result('db_fetch_assoc_prepared', []); + $plan = fv_nbar_connection_plan([2], $day - 1800, $day + 88200, time()); + expect($plan)->toHaveCount(3); + expect(array_column($plan, 'daily'))->toBe([false, true, false]); + expect($plan[1]['source'])->toContain('plugin_flowview_nbar_daily_connections', 'listener_id=2', 'hour=' . $day); + expect($plan[0]['source'])->toContain('minute>=' . ($day - 1800), 'minute<' . $day); + expect($plan[2]['source'])->toContain('minute>=' . ($day + 86400), 'minute<' . ($day + 88200)); +}); + +it('falls back to raw hours when a daily checkpoint is stale or uses another timezone', function ($reason) { + plugin_test_reset(); + $day = strtotime('-2 days midnight'); + plugin_test_queue_db_result('db_fetch_assoc_prepared', [['day' => $day, 'covered_to' => $day + 86400, 'timezone' => $reason === 'timezone' ? 'Other/Zone' : date_default_timezone_get(), 'revision' => '10']]); + plugin_test_queue_db_result('db_fetch_row_prepared', ['n' => 24, 'ready' => 24, 'rev' => '11']); + plugin_test_queue_db_result('db_fetch_assoc_prepared', []); + $plan = fv_nbar_connection_plan([2], $day, $day + 86400, time()); + expect($plan)->toHaveCount(24); + foreach ($plan as $part) { + expect($part['daily'])->toBeFalse(); + expect($part['source'])->toContain('plugin_flowview_nbar_data'); + } +})->with(['timezone', 'revision']); + +it('rejects failed planning metadata instead of returning an empty report', function ($stage) { + plugin_test_reset(); + if ($stage === 'hour') { + plugin_test_queue_db_result('db_fetch_assoc_prepared', []); + } + plugin_test_queue_db_result('db_fetch_assoc_prepared', false); + expect(fn () => fv_nbar_connection_plan([1], $this->start, $this->start + 7200, time()))->toThrow(RuntimeException::class); +})->with(['day', 'hour']); + + +it('keeps exporter name ranks ahead of endpoints when candidate pages contain ties', function () { + plugin_test_reset(); + $names = []; + $ranks = [3, 1, 0, 2]; + $labels = ['Zulu', 'Alpha', null, 'Bravo']; + foreach ([0, 1, 3] as $i) { + $names[] = ['listener_id' => 1, 'router' => '192.0.2.' . (201 + $i), 'app_id' => '0d000001', 'name_id' => $ranks[$i], 'name' => $labels[$i]]; + } + $tuples = []; + for ($i = 0; $i < 256; $i++) { + $tuples[] = array_replace($this->tuple, ['router' => '192.0.2.' . (201 + $i % 4), 'sport' => 1000 + intdiv($i, 4), 'bytes' => $i < 101 ? '10' : '1']); + } + plugin_test_queue_db_result('db_fetch_assoc_prepared', []); + plugin_test_queue_db_result('db_fetch_assoc_prepared', [['hour' => $this->start]]); + plugin_test_queue_db_result('db_fetch_assoc_prepared', $names); + plugin_test_queue_db_result('db_fetch_assoc_prepared', array_merge($tuples, [array_replace($this->tuple, ['sport' => 9000, 'bytes' => '1'])])); + plugin_test_queue_db_result('db_fetch_assoc_prepared', $tuples); + $rows = fv_nbar_connections_direct([1], $this->start, $this->start + 3600, '1=1', [], 1); + $expected = array_slice($tuples, 0, 101); + usort($expected, function ($a, $b) use ($ranks) { + $ai = (int) substr($a['router'], -3) - 201; + $bi = (int) substr($b['router'], -3) - 201; + return ($ranks[$ai] <=> $ranks[$bi]) ?: ($a['sport'] <=> $b['sport']); + }); + expect(array_column($rows, 'router'))->toBe(array_column($expected, 'router')); + expect(array_column($rows, 'sport'))->toBe(array_column($expected, 'sport')); + expect($rows[0]['name'])->toBeNull()->and($rows[100]['name'])->toBe('Zulu'); + foreach ($GLOBALS['__test_db_calls'] as $call) { + expect($call['sql'])->not->toContain('OFFSET'); + } +}); + +it('does not add an unrequested tuple returned by a candidate lookup', function () { + $unexpected = array_replace($this->tuple, ['sport' => 9999, 'bytes' => '9999999999999999999']); + foreach ([[$this->tuple], [], [$this->tuple, $unexpected], []] as $rows) { + plugin_test_queue_db_result('db_fetch_assoc_prepared', $rows); + } + $rows = fv_nbar_connections_direct([1], $this->start, $this->start + 7200, '1=1', [], 1); + expect($rows)->toHaveCount(1)->and($rows[0]['sport'])->toBe($this->tuple['sport'])->and($rows[0]['bytes'])->toBe($this->tuple['bytes']); +}); diff --git a/tests/Unit/NbarCountRequestTest.php b/tests/Unit/NbarCountRequestTest.php new file mode 100644 index 0000000..2ad7539 --- /dev/null +++ b/tests/Unit/NbarCountRequestTest.php @@ -0,0 +1,43 @@ +toBe($status); + $sql = implode(' ', array_column($result['calls'], 'sql')); + $entry = $result['session']['fv_nbar_counts'][str_repeat('a', 64)] ?? []; + if (in_array($scenario, ['ready', 'failed', 'background-ready', 'background-failed'], true)) { + expect($sql)->toContain('GET_LOCK', 'max_statement_time=90', 'RELEASE_LOCK'); + $counts = array_values(array_filter($result['calls'], fn ($c) => $c['fn'] === 'db_fetch_row_prepared')); + expect($counts[0]['params'])->toBe([3]); + if (str_contains($scenario, 'failed')) { + expect($entry['failed_until'])->toBeGreaterThan(time()); + expect($entry)->not->toHaveKey('total'); + } else { + expect($entry['total'])->toBe(12345); + expect($entry['until'])->toBeGreaterThan(time() + 590); + } + } else { + expect($sql)->not->toContain('max_statement_time=90'); + } + if ($status === 'ready') { + expect($payload)->toMatchArray(['pages' => 124, 'total' => 12345]); + } + + } finally { + foreach (glob($directory . '/*') as $file) { + unlink($file); + } + rmdir($directory); + } +})->with([ + ['malformed', 'expired'], ['missing', 'expired'], ['expired', 'expired'], ['retry', 'unavailable'], + ['cached', 'ready'], ['busy', 'busy'], ['ready', 'ready'], ['failed', 'unavailable'], + ['background-ready', 'busy'], ['background-failed', 'busy'] +]); diff --git a/tests/Unit/NbarDailyWorkerTest.php b/tests/Unit/NbarDailyWorkerTest.php new file mode 100644 index 0000000..5c4de6b --- /dev/null +++ b/tests/Unit/NbarDailyWorkerTest.php @@ -0,0 +1,140 @@ + date_default_timezone_get(), 'covered_to' => 1800003600, 'revision' => '3']); + plugin_test_queue_db_result('db_fetch_row_prepared', ['n' => 1, 'ready' => 1, 'rev' => '3']); + plugin_test_queue_db_result('db_fetch_row_prepared', ['n' => 2, 'ready' => 2, 'rev' => '4']); + plugin_test_queue_db_result('db_fetch_cell', '6.25'); + expect(fv_nbar_daily_build(2, 1800000000, 1800007200))->toBe(1); + $calls = $GLOBALS['__test_db_calls']; + $sql = implode(' ', array_column($calls, 'sql')); + expect($sql)->not->toContain('DELETE FROM'); + expect($sql)->toContain('minute>=1800003600 AND d.minute<1800007200', 'bytes=bytes+VALUES(bytes)', 'SET SESSION max_statement_time=6.25'); + $checkpoint = array_values(array_filter($calls, fn ($c) => str_contains($c['sql'], 'INSERT INTO plugin_flowview_nbar_days'))); + expect(array_slice($checkpoint[0]['params'], 0, 4))->toBe([2, 1800000000, 1800007200, '4']); +}); + +it('does not rebuild a completed daily checkpoint', function () { + plugin_test_queue_db_result('db_fetch_row_prepared', ['timezone' => date_default_timezone_get(), 'covered_to' => 1800003600, 'revision' => '3']); + plugin_test_queue_db_result('db_fetch_row_prepared', ['n' => 1, 'ready' => 1, 'rev' => '3']); + expect(fv_nbar_daily_build(2, 1800000000, 1800003600))->toBe(0); + $sql = array_column($GLOBALS['__test_db_calls'], 'sql'); + expect(end($sql))->toBe('ROLLBACK'); + expect(implode(' ', $sql))->not->toContain('INSERT INTO', 'DELETE FROM'); +}); + +it('does not publish a day when any daily mutation fails', function ($stage) { + plugin_test_queue_db_result('db_fetch_row_prepared', []); + plugin_test_queue_db_result('db_fetch_row_prepared', ['n' => 1, 'ready' => 1, 'rev' => '7']); + plugin_test_queue_db_result('db_fetch_cell', '4.5'); + if ($stage === 'reset') { + plugin_test_queue_db_result('db_execute_prepared', false); + } elseif ($stage === 'aggregate') { + foreach ([true, true, false] as $value) { + plugin_test_queue_db_result('db_execute', $value); + } + } elseif ($stage === 'checkpoint') { + foreach ([true, true, true, false] as $value) { + plugin_test_queue_db_result('db_execute_prepared', $value); + } + } else { + foreach ([true, true, true, true, true, false] as $value) { + plugin_test_queue_db_result('db_execute', $value); + } + } + expect(fv_nbar_daily_build(1, 1800000000, 1800003600))->toBe(0); + $sql = array_column($GLOBALS['__test_db_calls'], 'sql'); + expect($sql)->toContain('ROLLBACK'); + expect(end($sql))->toBe('SET SESSION max_statement_time=4.5'); +})->with(['reset', 'aggregate', 'checkpoint', 'commit']); + +it('skips daily maintenance when another worker owns the lock', function () { + plugin_test_queue_db_result('db_fetch_cell', 0); + expect(fv_nbar_daily_refresh())->toBe(0); + expect($GLOBALS['__test_db_calls'])->toHaveCount(1); +}); + +it('plans retained calendar days and seeds only empty hours before the first record', function () { + plugin_test_queue_db_result('db_fetch_cell', 1); + $first = strtotime('yesterday') + 3 * 3600; + plugin_test_queue_db_result('db_fetch_assoc', [['listener_id' => 2, 'first_hour' => $first], ['listener_id' => 3, 'first_hour' => $first]]); + plugin_test_queue_db_result('db_fetch_cell_prepared', $first); + plugin_test_queue_db_result('db_fetch_cell_prepared', false); + expect(fv_nbar_daily_refresh())->toBe(0); + $calls = $GLOBALS['__test_db_calls']; + $seeds = array_values(array_filter($calls, fn ($c) => str_contains($c['sql'], 'INSERT IGNORE INTO plugin_flowview_nbar_hours'))); + expect($seeds)->toHaveCount(3); + expect($seeds[0]['params'])->toBe([2, strtotime('yesterday')]); + $sql = implode(' ', array_column($calls, 'sql')); + expect($sql)->toContain('START TRANSACTION WITH CONSISTENT SNAPSHOT', 'RELEASE_LOCK'); + expect($sql)->not->toContain('INSERT INTO plugin_flowview_nbar_days'); +}); + +it('seeds and publishes closed hours before handing off to the daily worker', function () { + $hour = (int) floor((time() - 7200) / 3600) * 3600; + plugin_test_queue_db_result('db_fetch_row', ['acquired' => 1]); + plugin_test_queue_db_result('db_fetch_assoc', [['id' => 1], ['id' => 2]]); + plugin_test_queue_db_result('db_fetch_assoc', [['listener_id' => 1, 'hour' => $hour, 'revision' => 4]]); + plugin_test_queue_db_result('db_fetch_row_prepared', ['minute' => $hour]); + plugin_test_queue_db_result('db_fetch_row_prepared', []); + plugin_test_queue_db_result('db_fetch_row_prepared', ['revision' => 4]); + plugin_test_queue_db_result('db_fetch_row_prepared', ['revision' => 4]); + expect(fv_nbar_summary_refresh())->toBe(1); + $sql = implode(' ', array_column($GLOBALS['__test_db_calls'], 'sql')); + expect($sql)->toContain('INSERT IGNORE INTO plugin_flowview_nbar_hours', 'SET built=?,connection_built=?', 'RELEASE_LOCK', "flowview_nbar_daily:"); +}); + +it('releases the hourly worker lock when checkpoint seeding fails', function () { + plugin_test_queue_db_result('db_fetch_row', ['acquired' => 1]); + plugin_test_queue_db_result('db_fetch_assoc', [['id' => 1]]); + plugin_test_queue_db_result('db_fetch_row_prepared', ['minute' => time() - 7200]); + plugin_test_queue_db_result('db_execute', false); + expect(fv_nbar_summary_refresh())->toBe(0); + $sql = array_column($GLOBALS['__test_db_calls'], 'sql'); + expect(end($sql))->toContain('RELEASE_LOCK'); + expect(implode(' ', $sql))->not->toContain('START TRANSACTION'); +}); + +it('stops the hourly work queue when its time budget expires', function () { + plugin_test_queue_db_result('db_fetch_row', ['acquired' => 1]); + plugin_test_queue_db_result('db_fetch_assoc', static function () { usleep(1100000); return []; }); + plugin_test_queue_db_result('db_fetch_assoc', [['listener_id' => 1, 'hour' => time() - 7200, 'revision' => 4]]); + expect(fv_nbar_summary_refresh(1))->toBe(0); + $sql = implode(' ', array_column($GLOBALS['__test_db_calls'], 'sql')); + expect($sql)->toContain('RELEASE_LOCK'); + expect($sql)->not->toContain('START TRANSACTION'); +}); + +it('stops daily work after the worker deadline while releasing its lock', function () { + plugin_test_queue_db_result('db_fetch_cell', 1); + plugin_test_queue_db_result('db_fetch_assoc', static function () { usleep(1100000); return [['listener_id' => 1, 'first_hour' => time() - 7200]]; }); + plugin_test_queue_db_result('db_fetch_cell_prepared', time() - 7200); + expect(fv_nbar_daily_refresh(1))->toBe(0); + $sql = implode(' ', array_column($GLOBALS['__test_db_calls'], 'sql')); + expect($sql)->toContain('RELEASE_LOCK'); + expect($sql)->not->toContain('START TRANSACTION'); +}); + +it('does not materialize a calendar day that begins before the retention cutoff', function () { + $cutoff = time() - fv_nbar_retention_days() * 86400; + plugin_test_queue_db_result('db_fetch_cell', 1); + plugin_test_queue_db_result('db_fetch_assoc', [['listener_id' => 2, 'first_hour' => $cutoff - 86400]]); + plugin_test_queue_db_result('db_fetch_cell_prepared', $cutoff - 86400); + expect(fv_nbar_daily_refresh())->toBe(0); + $lookups = array_values(array_filter($GLOBALS['__test_db_calls'], fn ($call) => str_starts_with($call['sql'], 'SELECT * FROM plugin_flowview_nbar_days'))); + expect($lookups)->not->toBe([]); + foreach ($lookups as $lookup) { + expect($lookup['params'][1])->toBeGreaterThanOrEqual($cutoff); + } +}); diff --git a/tests/Unit/NbarDirectRequestTest.php b/tests/Unit/NbarDirectRequestTest.php new file mode 100644 index 0000000..7826409 --- /dev/null +++ b/tests/Unit/NbarDirectRequestTest.php @@ -0,0 +1,24 @@ +toBe(''); + expect($result['status'])->toBe($expected); + + } finally { + foreach (glob($directory . '/*') as $file) { + unlink($file); + } + rmdir($directory); + } +})->with([ + ['database', false], ['common', false], ['connections', false], ['query', false], + ['daily', false], ['seen', false], ['summary', false], ['timespan', false], + ['preferences', 403], ['guide', 403], ['report', false], ['collector', false] +]); diff --git a/tests/Unit/NbarDnsTest.php b/tests/Unit/NbarDnsTest.php new file mode 100644 index 0000000..38f2956 --- /dev/null +++ b/tests/Unit/NbarDnsTest.php @@ -0,0 +1,80 @@ + $result]; + } + } +} + +it('uses persistent DNS results and bounded positive and negative resolver caches', function () { + plugin_test_reset(); + flowview_test_load(dirname(__DIR__, 2) . '/setup.php'); + flowview_test_load(dirname(__DIR__, 2) . '/nbar_common.php'); + flowview_test_load(dirname(__DIR__, 2) . '/nbar_dns.php'); + $GLOBALS['fv_nbar_preferences'] = ['dns' => '1']; + plugin_test_queue_db_result('db_fetch_row_prepared', ['host' => 'cached.example']); + expect(fv_nbar_dns('192.0.2.201'))->toBe('cached.example'); + plugin_test_queue_db_result('db_fetch_row_prepared', ['host' => '192.0.2.202']); + expect(fv_nbar_dns('192.0.2.202'))->toBe('No reverse DNS'); + $_SESSION['fv_nbar_dns_negative']['192.0.2.203'] = ['until' => time() + 30, 'name' => 'Not resolved']; + expect(fv_nbar_dns('192.0.2.203'))->toBe('Not resolved'); + Net_DNS2_Resolver::$results = [ + [(object) ['ptrdname' => 'resolved.example.']], + [], + new Exception('NXDOMAIN', 3), + new Exception('Timeout', 1), + ]; + expect(fv_nbar_dns('192.0.2.204'))->toBe('resolved.example'); + expect(fv_nbar_dns('192.0.2.204'))->toBe('resolved.example'); + expect(fv_nbar_dns('192.0.2.205'))->toBe('No reverse DNS'); + expect(fv_nbar_dns('192.0.2.206'))->toBe('No reverse DNS'); + expect(fv_nbar_dns('192.0.2.207'))->toBe('Not resolved'); + expect(Net_DNS2_Resolver::$queries)->toHaveCount(4); + expect($_SESSION['fv_nbar_dns_negative']['192.0.2.206']['until'])->toBeGreaterThan(time() + 290); + expect($_SESSION['fv_nbar_dns_negative']['192.0.2.207']['until'])->toBeLessThanOrEqual(time() + 30); + $calls = array_filter($GLOBALS['__test_db_calls'], fn ($c) => $c['fn'] === 'db_execute_prepared'); + expect(array_values($calls)[0]['params'][1])->toBe('resolved.example'); +}); + +it('expires negative DNS entries and retains at most 512 addresses', function () { + plugin_test_reset(); + flowview_test_load(dirname(__DIR__, 2) . '/nbar_dns.php'); + $_SESSION['fv_nbar_dns_negative']['old'] = ['until' => time() - 1, 'name' => 'Old']; + foreach (range(1, 512) as $i) { + $_SESSION['fv_nbar_dns_negative']['host' . $i] = ['until' => time() + 100, 'name' => 'Not resolved']; + } + expect(fv_nbar_dns_negative('192.0.2.208', 'Not resolved', 30))->toBe('Not resolved'); + expect($_SESSION['fv_nbar_dns_negative'])->toHaveCount(512); + expect($_SESSION['fv_nbar_dns_negative'])->not->toHaveKey('old')->not->toHaveKey('host1'); + expect($_SESSION['fv_nbar_dns_negative'])->toHaveKey('192.0.2.208'); +}); + +require_once dirname(__DIR__) . '/helpers/request.php'; +it('stops further DNS lookups after the report lookup budget is exhausted', function () { + $directory = sys_get_temp_dir() . '/flowview-dns-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + try { + [$stdout] = flowview_test_request(dirname(__DIR__) . '/fixtures/nbar-dns-budget.php', 'budget', $directory); + expect(json_decode($stdout, true, 512, JSON_THROW_ON_ERROR))->toBe(['name' => 'Not resolved', 'queries' => 1]); + } finally { + foreach (glob($directory . '/*') as $file) { + unlink($file); + } + rmdir($directory); + } +}); diff --git a/tests/Unit/NbarFailurePathsTest.php b/tests/Unit/NbarFailurePathsTest.php new file mode 100644 index 0000000..9b4b788 --- /dev/null +++ b/tests/Unit/NbarFailurePathsTest.php @@ -0,0 +1,110 @@ + 'nbar']; + flowview_test_load(dirname(__DIR__, 2) . '/flowview_sections.php'); +}); + +beforeEach(function () { + plugin_test_reset(); +}); + +it('compares identical connection tuples consistently', function () { + $row = ['bytes' => '9007199254740993', '_key' => ['0d000001', 1, 'C0000201', 'C6336401', 1000, 443, 6]]; + expect(fv_nbar_connection_compare($row, $row))->toBe(0); + expect(fv_nbar_connection_compare($row, array_replace($row, ['bytes' => '9007199254740994'])))->toBeGreaterThan(0); +}); + +it('normalizes saved navigation fields without restoring NBAR into Flow Reports', function () { + $GLOBALS['__test_user_settings']['flowview_section_flows'] = json_encode(['report' => 'nbar', 'query' => ['bad'], 'mode' => 'apps']); + $params = fv_section_params('flows'); + expect($params)->not->toHaveKey('report')->not->toHaveKey('query'); + expect($params['section'])->toBe('flows'); +}); + +it('does not draw a chart slice for a zero-byte application', function () { + $GLOBALS['lid'] = 1; + plugin_test_queue_db_result('db_fetch_assoc_prepared', [['name' => 'Empty app', 'bytes' => 0]]); + ob_start(); + fv_nbar_chart('d.listener_id=?', [1, time() - 1200, time()], 'all'); + $html = ob_get_clean(); + expect($html)->not->toContain('Empty app'); +}); + +it('stops hourly schema setup on creation or column-upgrade failure', function ($stage) { + if ($stage === 'column') { + foreach (['hours', 'apps', 'pairs', 'connections'] as $kind) { + plugin_test_queue_db_result('db_fetch_cell', 'plugin_flowview_nbar_' . $kind); + } + plugin_test_queue_db_result('db_add_column', false); + } + expect(fv_nbar_summary_schema())->toBeFalse(); +})->with(['create', 'column']); + +it('stops daily schema setup on table or index failures', function ($stage) { + if ($stage === 'index') { + foreach (['apps', 'pairs', 'connections'] as $kind) { + plugin_test_queue_db_result('db_fetch_cell', 'plugin_flowview_nbar_daily_' . $kind); + } + foreach ([true, true, true, false] as $result) { + plugin_test_queue_db_result('db_execute', $result); + } + } + expect(fv_nbar_daily_schema())->toBeFalse(); +})->with(['create', 'index']); + +it('does not complete observation schema setup after failed checkpoint writes', function ($stage) { + foreach (range(1, 12) as $unused) { + plugin_test_queue_db_result('db_fetch_row', ['Field' => 'present']); + } + if ($stage === 'column') { + plugin_test_queue_db_result('db_fetch_cell', 'plugin_flowview_nbar_seen_upgrade'); + plugin_test_queue_db_result('db_add_column', false); + } elseif ($stage === 'seed') { + plugin_test_queue_db_result('db_fetch_cell', ''); + plugin_test_queue_db_result('db_fetch_cell', 'plugin_flowview_nbar_seen_upgrade'); + plugin_test_queue_db_result('db_execute', true); + plugin_test_queue_db_result('db_execute', false); + } + expect(fv_nbar_seen_schema())->toBeFalse(); +})->with(['create', 'seed', 'column']); + +it('does not build summaries when transaction creation or commit fails', function ($stage) { + if ($stage === 'start') { + plugin_test_queue_db_result('db_execute', false); + expect(fv_nbar_summary_build(1, 1800000000))->toBeFalse(); + plugin_test_queue_db_result('db_execute', false); + expect(fv_nbar_daily_build(1, 1800000000, 1800003600))->toBe(0); + } else { + plugin_test_queue_db_result('db_fetch_row_prepared', ['revision' => 4]); + plugin_test_queue_db_result('db_fetch_row_prepared', ['revision' => 4]); + foreach ([true, true, true, false] as $result) { + plugin_test_queue_db_result('db_execute', $result); + } + expect(fv_nbar_summary_build(1, 1800000000))->toBeFalse(); + expect(array_column($GLOBALS['__test_db_calls'], 'sql'))->toContain('ROLLBACK'); + } +})->with(['start', 'commit']); + +it('does not turn failed name or deep-page queries into an empty report', function ($stage) { + foreach (range(1, 2) as $unused) { + plugin_test_queue_db_result('db_fetch_assoc_prepared', []); + } + if ($stage === 'page') { + plugin_test_queue_db_result('db_fetch_assoc_prepared', []); + } + plugin_test_queue_db_result('db_fetch_assoc_prepared', false); + expect(fn () => fv_nbar_connections_direct([1], 1800000000, 1800001800, '1=1', [], $stage === 'page' ? 500 : 1))->toThrow(RuntimeException::class); + expect(array_column($GLOBALS['__test_db_calls'], 'sql'))->toContain('ROLLBACK'); +})->with(['names', 'page']); + +it('rejects a nonpositive capture identifier inside the snapshot planner', function () { + expect(fn () => fv_nbar_connections_direct([0], 1800000000, 1800001800, '1=1', [], 1))->toThrow(InvalidArgumentException::class); + expect(array_column($GLOBALS['__test_db_calls'], 'sql'))->toContain('ROLLBACK'); +}); diff --git a/tests/Unit/NbarLatestReviewTest.php b/tests/Unit/NbarLatestReviewTest.php new file mode 100644 index 0000000..f23fed4 --- /dev/null +++ b/tests/Unit/NbarLatestReviewTest.php @@ -0,0 +1,77 @@ + ['report' => 7, 'predefined_timespan' => 4]]; + $_REQUEST = ['report' => 'nbar', 'predefined_timespan' => $preset, 'date1' => '2026-10-10 00:00', 'date2' => '2026-10-10 01:00']; + $session = $_SESSION; + ob_start(); + expect(fv_nbar_timespan_request())->toBeTrue(); + $payload = json_decode(ob_get_clean(), true); + expect($payload)->toHaveKeys(['begin_now', 'end_now']); + expect($_SESSION)->toBe($session); +})->with([0, 1, 4, 1000, 'invalid']); +it('leaves legacy timespan dispatch to its original handler', function () { + $_REQUEST = ['report' => '7']; + expect(fv_nbar_timespan_request())->toBeFalse(); +}); +it('distinguishes a status read failure from an empty feed and keeps other captures visible', function () { + plugin_test_queue_db_result('db_fetch_row_prepared', false); + plugin_test_queue_db_result('db_fetch_row_prepared', ['minute' => time()]); + ob_start(); + fv_nbar_feed_status([['id' => 1, 'name' => 'Broken'], ['id' => 2, 'name' => 'Healthy']], [1, 2]); + $html = ob_get_clean(); + expect($html)->toContain('Broken: Unable to load NBAR status. Please try again.', 'Healthy: Receiving')->not->toContain('No retained NBAR records', 'No recent NBAR'); +}); +it('excludes multicast in both endpoint predicates', function () { + foreach (['d.src', 'd.dst'] as $field) { + $sql = fv_nbar_unicast_sql($field); + expect($sql)->toContain("LENGTH($field)=4", '& 240)=224', "LENGTH($field)=16", '=255'); + } + expect(fn () => fv_nbar_unicast_sql('unsafe'))->toThrow(InvalidArgumentException::class); +}); +it('drains more than one batch fairly across retention tables', function () { + foreach ([50000, 50000, 1, 1] as $count) { + plugin_test_queue_db_result('db_affected_rows', $count); + } + fv_nbar_cleanup_batches([['DELETE fixtureA LIMIT 50000', 50000], ['DELETE fixtureB LIMIT 50000', 50000]], microtime(true) + 1); + expect(array_column($GLOBALS['__test_db_calls'], 'sql'))->toBe(['DELETE fixtureA LIMIT 50000', 'DELETE fixtureB LIMIT 50000', 'DELETE fixtureA LIMIT 50000', 'DELETE fixtureB LIMIT 50000']); +}); +it('stops cleanup at its deadline and drops failed batches', function () { + fv_nbar_cleanup_batches([['expired', 1]], microtime(true) - 1); + expect($GLOBALS['__test_db_calls'])->toBe([]); + plugin_test_queue_db_result('db_execute', false); + fv_nbar_cleanup_batches([['failed', 1]], microtime(true) + 1); + expect(array_column($GLOBALS['__test_db_calls'], 'sql'))->toBe(['failed']); +}); +it('checks the shared deadline between cleanup statements', function () { + plugin_test_queue_db_result('db_execute', function () { + usleep(3000); + return true; + }); + fv_nbar_cleanup_batches([['first', 1], ['second', 1]], microtime(true) + 0.001); + expect(array_column($GLOBALS['__test_db_calls'], 'sql'))->toBe(['first']); +}); +it('drains the daily retention tables through the shared worker', function () { + fv_nbar_daily_cleanup(); + expect($GLOBALS['__test_db_calls'])->toHaveCount(4); +}); diff --git a/tests/Unit/NbarMaintenanceTest.php b/tests/Unit/NbarMaintenanceTest.php new file mode 100644 index 0000000..45dadc4 --- /dev/null +++ b/tests/Unit/NbarMaintenanceTest.php @@ -0,0 +1,198 @@ + true, 1800003600 => true], [42]))->toBeTrue(); + $calls = $GLOBALS['__test_db_calls']; + expect(array_column($calls, 'sql')[0])->toBe('START TRANSACTION'); + expect($calls[1]['params'])->toBe([42]); + expect($calls[2]['sql'])->toContain('(3,1800000000,1,-1)', '(3,1800003600,1,-1)', 'revision=revision+1'); + expect(end($calls)['sql'])->toBe('COMMIT'); + foreach ($calls as $call) { + expect($call['conn'])->toBe('maintenance-test-connection'); + } +}); + +it('does not publish revisions when transaction creation fails', function () { + plugin_test_queue_db_result('db_execute', false); + expect(fv_nbar_summary_store('INSERT fixture VALUES (?)', 3, [1800000000 => true], [42]))->toBeFalse(); + expect($GLOBALS['__test_db_calls'])->toHaveCount(1); +}); + +it('rolls back when invalidation fails after the raw write', function () { + plugin_test_queue_db_result('db_execute', true); + plugin_test_queue_db_result('db_execute', false); + expect(fv_nbar_summary_store('INSERT fixture VALUES (?)', 3, [1800000000 => true], [42]))->toBeFalse(); + $sql = array_column($GLOBALS['__test_db_calls'], 'sql'); + expect(end($sql))->toBe('ROLLBACK'); + expect($sql)->not->toContain('COMMIT'); +}); + +it('rebuilds all hourly tables and publishes only a matching revision', function () { + plugin_test_queue_db_result('db_fetch_row_prepared', ['revision' => '123']); + plugin_test_queue_db_result('db_fetch_row_prepared', ['revision' => '123']); + expect(fv_nbar_summary_build(3, 1800000000))->toBeTrue(); + $calls = $GLOBALS['__test_db_calls']; + $sql = array_column($calls, 'sql'); + expect($sql[count($sql) - 2])->toBe('COMMIT'); + expect(end($sql))->toBe('SET SESSION max_statement_time=0'); + $publish = array_values(array_filter($calls, fn ($c) => str_starts_with($c['sql'], 'UPDATE plugin_flowview_nbar_hours'))); + expect($publish[0]['params'])->toBe(['123', '123', 3, 1800000000]); +}); + +it('rolls back each failed hourly mutation without committing partial totals', function ($failure) { + plugin_test_queue_db_result('db_fetch_row_prepared', ['revision' => '123']); + plugin_test_queue_db_result('db_fetch_row_prepared', ['revision' => '123']); + for ($i = 0; $i <= $failure; $i++) { + plugin_test_queue_db_result('db_execute_prepared', $i !== $failure); + } + expect(fv_nbar_summary_build(3, 1800000000))->toBeFalse(); + $sql = array_column($GLOBALS['__test_db_calls'], 'sql'); + expect($sql[count($sql) - 2])->toBe('ROLLBACK'); + expect(end($sql))->toBe('SET SESSION max_statement_time=0'); + expect($sql)->not->toContain('COMMIT'); +})->with(range(0, 6)); + +it('rejects hourly builds with missing or changed revisions', function ($latest) { + plugin_test_queue_db_result('db_fetch_row_prepared', ['revision' => '123']); + plugin_test_queue_db_result('db_fetch_row_prepared', $latest); + expect(fv_nbar_summary_build(3, 1800000000))->toBeFalse(); + $sql = array_column($GLOBALS['__test_db_calls'], 'sql'); + expect($sql[count($sql) - 2])->toBe('ROLLBACK'); + expect(end($sql))->toBe('SET SESSION max_statement_time=0'); + expect(implode(' ', $sql))->not->toContain('UPDATE plugin_flowview_nbar_hours SET built'); +})->with([[['revision' => '124']], [[]]]); + +it('rejects a build with no initial hourly state', function () { + expect(fv_nbar_summary_build(3, 1800000000))->toBeFalse(); + $sql = array_column($GLOBALS['__test_db_calls'], 'sql'); + expect($sql[count($sql) - 2])->toBe('ROLLBACK'); + expect(end($sql))->toBe('SET SESSION max_statement_time=0'); +}); + +it('skips hourly maintenance while another worker holds its lock', function () { + expect(fv_nbar_summary_refresh())->toBe(0); + expect($GLOBALS['__test_db_calls'])->toHaveCount(1); +}); + +it('requires all overlapping hours to be ready before using a daily revision', function ($state, $expected) { + plugin_test_queue_db_result('db_fetch_row_prepared', $state); + expect(fv_nbar_daily_signature(3, 1800000000, 1800007200))->toBe($expected); +})->with([ + [['n' => 2, 'ready' => 2, 'rev' => '18446744073709551616'], '18446744073709551616'], + [['n' => 1, 'ready' => 1, 'rev' => '1'], null], + [['n' => 2, 'ready' => 1, 'rev' => '1'], null], + [[], null], +]); + +it('fails closed for untrusted daily signature text', function () { + $sql = fv_nbar_daily_guard(3, 1800000000, 1800007200, '1 OR 1=1'); + expect($sql)->toContain('revision=-1'); + expect($sql)->not->toContain('OR 1=1'); +}); + +it('keeps observation dates unknown when any contributing row lacks dates', function () { + $total = ['first_seen' => null, 'last_seen' => null, '_seen_missing' => false]; + fv_nbar_seen_merge($total, ['first_seen' => 20, 'last_seen' => 30]); + fv_nbar_seen_merge($total, ['first_seen' => 10, 'last_seen' => 25]); + expect($total['first_seen'])->toBe(10); + expect($total['last_seen'])->toBe(30); + fv_nbar_seen_merge($total, ['first_seen' => null, 'last_seen' => 50]); + expect($total['_seen_missing'])->toBeTrue(); +}); + +it('does not start date backfill without both scoped locks', function () { + plugin_test_queue_db_result('db_fetch_cell', 1); + plugin_test_queue_db_result('db_fetch_cell', 'REPEATABLE-READ'); + plugin_test_queue_db_result('db_fetch_cell', 0); + expect(fv_nbar_seen_backfill())->toBe(0); + $sql = implode(' ', array_column($GLOBALS['__test_db_calls'], 'sql')); + expect($sql)->toContain('RELEASE_LOCK', 'REPEATABLE READ'); + expect($sql)->not->toContain('UPDATE plugin_flowview_nbar_'); +}); + +it('expires all summary levels with bounded database deletions', function () { + fv_nbar_cleanup(); + $sql = array_column($GLOBALS['__test_db_calls'], 'sql'); + expect($sql)->toHaveCount(9); + foreach ($sql as $statement) { + expect($statement)->toStartWith('DELETE FROM plugin_flowview_nbar_'); + expect($statement)->toContain(' LIMIT '); + } +}); + +it('rejects unknown summary types before constructing SQL', function () { + expect(fn () => fv_nbar_hourly_source('untrusted', [1], 0, 3600))->toThrow(InvalidArgumentException::class); + expect(fn () => fv_nbar_summary_source('untrusted', [1], 0, 3600))->toThrow(InvalidArgumentException::class); +}); + +it('keeps summary and raw branches mutually exclusive for complete hours', function () { + foreach (['apps', 'pairs', 'connections'] as $kind) { + $sql = fv_nbar_hourly_source($kind, [3], 1800000010, 1800010810, 1800100000); + expect($sql)->toContain('UNION ALL', 'NOT (', 'listener_id=3'); + expect($sql)->toContain('minute>=1800000010', 'minute<1800010810'); + } +}); + + +it('restores the database timeout after building or rolling back a daily block', function ($fail) { + plugin_test_queue_db_result('db_fetch_row_prepared', []); + plugin_test_queue_db_result('db_fetch_row_prepared', ['n' => 1, 'ready' => 1, 'rev' => '42']); + plugin_test_queue_db_result('db_fetch_cell', '7.5'); + if ($fail) { + plugin_test_queue_db_result('db_execute', true); + plugin_test_queue_db_result('db_execute', true); + plugin_test_queue_db_result('db_execute', false); + } + expect(fv_nbar_daily_build(3, 1800000000, 1800003600))->toBe($fail ? 0 : 1); + $sql = array_column($GLOBALS['__test_db_calls'], 'sql'); + expect(end($sql))->toBe('SET SESSION max_statement_time=7.5'); + expect($sql)->toContain($fail ? 'ROLLBACK' : 'COMMIT'); +})->with([false, true]); + +it('caches exact-count contexts separately by account and filter', function () { + flowview_test_load(dirname(__DIR__, 2) . '/nbar_count.php'); + $_SESSION['sess_user_id'] = 1; + $a = fv_nbar_count_context('(SELECT fixture)', 'd.listener_id=?', [1], time()); + $b = fv_nbar_count_context('(SELECT fixture)', 'd.listener_id=?', [2], time() - 3600); + $_SESSION['sess_user_id'] = 2; + $c = fv_nbar_count_context('(SELECT fixture)', 'd.listener_id=?', [1], time()); + expect($a)->not->toBe($b)->not->toBe($c); + expect($_SESSION['fv_nbar_counts'][$a]['ttl'])->toBe(60); + expect($_SESSION['fv_nbar_counts'][$b]['ttl'])->toBe(600); + expect($_SESSION['fv_nbar_counts'][$a]['sql'])->toContain('COUNT(DISTINCT', 'd.listener_id=?'); + expect($_SESSION['fv_nbar_counts'][$a]['args'])->toBe([1]); + expect(fv_nbar_count_context('(SELECT fixture)', 'd.listener_id=?', [1], time()))->toBe($c); +}); + +it('expires old count contexts and bounds the account cache', function () { + flowview_test_load(dirname(__DIR__, 2) . '/nbar_count.php'); + $_SESSION['fv_nbar_counts'] = ['expired' => ['expires' => time() - 1]]; + foreach (range(1, 12) as $i) { + $_SESSION['fv_nbar_counts']['context' . $i] = ['expires' => time() + 3600]; + } + $key = fv_nbar_count_context('(SELECT fixture)', '1=1', [], time()); + expect($_SESSION['fv_nbar_counts'])->toHaveCount(12); + expect($_SESSION['fv_nbar_counts'])->not->toHaveKey('expired')->not->toHaveKey('context1'); + expect($_SESSION['fv_nbar_counts'])->toHaveKey($key); +}); + +it('uses summary tuple fields for counts without aggregating traffic counters', function () { + flowview_test_load(dirname(__DIR__, 2) . '/nbar_count.php'); + $key = fv_nbar_count_context('', 'd.listener_id=? AND d.minute>=? AND d.minutetoContain('COUNT(DISTINCT', 'plugin_flowview_nbar_data', 'd.listener_id=?'); + expect($sql)->not->toContain('SUM(d.bytes)'); +}); diff --git a/tests/Unit/NbarPacketTest.php b/tests/Unit/NbarPacketTest.php new file mode 100644 index 0000000..3ea180f --- /dev/null +++ b/tests/Unit/NbarPacketTest.php @@ -0,0 +1,152 @@ +header = pack('nnNNNN', 9, 2, 100000, time(), 1, 902); +}); + +it('learns an application name from a v9 options template', function () { + $definitions = pack('nnnnnn', 1, 4, 95, 4, 96, 32); + $template = pack('nnnnn', 1, 10 + strlen($definitions), 901, 4, 8) . $definitions; + $record = inet_pton('192.0.2.1') . hex2bin('0d000001') . str_pad('youtube', 32, chr(0)); + $data = pack('nn', 901, 4 + strlen($record)) . $record; + fv_nbar_packet($this->header . $template . $data, '192.0.2.91'); + $calls = array_values(array_filter($GLOBALS['__test_db_calls'], fn ($c) => $c['fn'] === 'db_execute_prepared')); + expect($calls)->toHaveCount(1); + expect($calls[0]['sql'])->toContain('INSERT INTO plugin_flowview_nbar_names'); + expect(array_slice($calls[0]['params'], 0, 3))->toBe(['192.0.2.91', '0d000001', 'youtube']); +}); + +it('ignores malformed template definitions without writing records', function ($definition) { + fv_nbar_packet($this->header . $definition, '192.0.2.92'); + expect($GLOBALS['__test_db_calls'])->toBe([]); +})->with([pack('nnnn', 0, 8, 12, 1), pack('nnnn', 0, 8, 902, 1), pack('nnnnn', 1, 10, 903, 1, 0)]); + +it('uses exporter uptime to date records and rejects expired observations', function ($expired) { + $fields = [95 => 4, 8 => 4, 12 => 4, 1 => 4, 21 => 4]; + $definitions = ''; + foreach ($fields as $id => $size) { + $definitions .= pack('nn', $id, $size); + } + $template = pack('nnnn', 0, 8 + strlen($definitions), 904, count($fields)) . $definitions; + $record = hex2bin('0d000001') . inet_pton('192.0.2.10') . inet_pton('198.51.100.20') . pack('NN', 1000, 95000); + $data = pack('nn', 904, 4 + strlen($record)) . $record; + $now = time(); + fv_nbar_packet(pack('nnNNNN', 9, 2, 100000, $expired ? $now - 8 * 86400 : $now, 1, 904) . $template . $data, '192.0.2.94'); + $calls = array_values(array_filter($GLOBALS['__test_db_calls'], fn ($c) => $c['fn'] === 'db_execute_prepared')); + if ($expired) { + expect($calls)->toBe([]); + } else { + expect($calls)->toHaveCount(1); + expect($calls[0]['params'][1])->toBe((int) (floor(($now - 5) / 60) * 60)); + } +})->with([false, true]); + +it('ignores incomplete records and reports a failed record transaction', function ($reason) { + $fields = $reason === 'application' ? [8 => 4, 12 => 4, 1 => 4] : [95 => 4, 8 => $reason === 'address' ? 1 : 4, 12 => 4, 1 => 4]; + $definitions = ''; + $record = ''; + foreach ($fields as $id => $size) { + $definitions .= pack('nn', $id, $size); + $record .= str_repeat(chr(1), $size); + } + $template = pack('nnnn', 0, 8 + strlen($definitions), 905, count($fields)) . $definitions; + $data = pack('nn', 905, 4 + strlen($record)) . $record; + if ($reason === 'transaction') { + plugin_test_queue_db_result('db_execute', false); + } + fv_nbar_packet($this->header . $template . $data, '192.0.2.95'); + if ($reason === 'transaction') { + expect(end($GLOBALS['__test_cacti_log']))->toBe('WARNING: NBAR record transaction failed'); + } else { + expect($GLOBALS['__test_db_calls'])->toBe([]); + } +})->with(['application', 'address', 'transaction']); + +it('invalidates cached templates containing any zero-length field and recovers on a valid refresh', function ($options, $position) { + $tid = 960 + $position + ($options ? 10 : 0); + $router = '192.0.2.96'; + $fields = $options ? [[1, 4], [95, 4], [96, 32]] : [[95, 4], [8, 4], [12, 4], [1, 4]]; + $record = $options ? inet_pton('192.0.2.1') . hex2bin('0d000001') . str_pad('youtube', 32, chr(0)) : hex2bin('0d000001') . inet_pton('192.0.2.10') . inet_pton('198.51.100.20') . pack('N', 1000); + $encode = static function ($fields) use ($options, $tid) { + $definitions = implode('', array_map(fn ($f) => pack('nn', ...$f), $fields)); + return ($options ? pack('nnnnn', 1, 10 + strlen($definitions), $tid, 4, strlen($definitions) - 4) : pack('nnnn', 0, 8 + strlen($definitions), $tid, count($fields))) . $definitions; + }; + $data = pack('nn', $tid, 4 + strlen($record)) . $record; + fv_nbar_packet($this->header . $encode($fields) . $data, $router); + expect($GLOBALS['__test_db_calls'])->not->toBe([]); + plugin_test_reset(); + $invalid = $fields; + array_splice($invalid, $position, 0, [[999, 0]]); + fv_nbar_packet($this->header . $encode($invalid) . $data, $router); + fv_nbar_packet($this->header . $data, $router); + expect($GLOBALS['__test_db_calls'])->toBe([]); + fv_nbar_packet($this->header . $encode($fields) . $data, $router); + expect($GLOBALS['__test_db_calls'])->not->toBe([]); +})->with([[false, 0], [false, 2], [false, 4], [true, 0], [true, 2], [true, 3]]); + +it('rejects a large zero-length template before repeatedly parsing its data', function () { + $definitions = str_repeat(pack('nn', 999, 0), 1999) . pack('nn', 95, 1); + $template = pack('nnnn', 0, 8 + strlen($definitions), 990, 2000) . $definitions; + $data = pack('nn', 990, 56004) . str_repeat('x', 56000); + fv_nbar_packet($this->header . $template . $data, '192.0.2.99'); + fv_nbar_packet($this->header . $data, '192.0.2.99'); + expect($GLOBALS['__test_db_calls'])->toBe([]); +}); + +it('invalidates data templates with oversized numeric fields before decoding', function ($field, $width) { + $tid = 1100 + $field; + $router = '192.0.2.110'; + $fields = [[95, 4], [8, 4], [12, 4], [1, 4]]; + $encode = static function ($fields) use ($tid) { + $def = implode('', array_map(fn ($f) => pack('nn', ...$f), $fields)); + return pack('nnnn', 0, 8 + strlen($def), $tid, count($fields)) . $def; + }; + $record = hex2bin('0d000001') . inet_pton('192.0.2.1') . inet_pton('198.51.100.1') . pack('N', 10); + $normal = pack('nn', $tid, 4 + strlen($record)) . $record; + fv_nbar_packet($this->header . $encode($fields) . $normal, $router); + plugin_test_reset(); + $invalid = $fields; + $invalid[] = [$field, $width]; + $data = pack('nn', $tid, 4 + strlen($record) + $width) . $record . str_repeat('x', $width); + fv_nbar_packet($this->header . $encode($invalid) . $data, $router); + fv_nbar_packet($this->header . $normal, $router); + expect($GLOBALS['__test_db_calls'])->toBe([]); +})->with([[1, 4096], [2, 4096], [4, 4096], [7, 4096], [11, 4096], [21, 4096], [4, 2], [7, 3], [11, 3], [21, 5]]); + +it('keeps wide options scopes separate from numeric data field widths', function () { + $def = pack('nnnnnn', 1, 16, 95, 4, 96, 32); + $template = pack('nnnnn', 1, 10 + strlen($def), 1190, 4, 8) . $def; + $record = inet_pton('2001:db8::1') . hex2bin('0d000001') . str_pad('youtube', 32, chr(0)); + fv_nbar_packet($this->header . $template . pack('nn', 1190, 4 + strlen($record)) . $record, '192.0.2.119'); + expect($GLOBALS['__test_db_calls'])->toHaveCount(1)->and($GLOBALS['__test_db_calls'][0]['params'][2])->toBe('youtube'); +}); + +it('does not interpret options scopes as flow counters when names are absent', function () { + $def = pack('nnnnnnnn', 1, 16, 95, 4, 8, 4, 12, 4); + $template = pack('nnnnn', 1, 10 + strlen($def), 1191, 4, 12) . $def; + $record = inet_pton('2001:db8::1') . hex2bin('0d000001') . inet_pton('192.0.2.1') . inet_pton('198.51.100.1'); + fv_nbar_packet($this->header . $template . pack('nn', 1191, 4 + strlen($record)) . $record, '192.0.2.119'); + expect($GLOBALS['__test_db_calls'])->toBe([]); +}); + +it('bounds packet-controlled source IDs while continuing to decode recent templates', function () { + $def = pack('nnnnnnnn', 95, 4, 8, 4, 12, 4, 1, 4); + $template = pack('nnnn', 0, 8 + strlen($def), 1300, 4) . $def; + $record = hex2bin('0d000001') . inet_pton('192.0.2.1') . inet_pton('198.51.100.1') . pack('N', 10); + $data = pack('nn', 1300, 4 + strlen($record)) . $record; + for ($i = 0;$i < 70;$i++) { + fv_nbar_packet(pack('nnNNNN', 9, 1, 100000, time(), 1, 1000 + $i) . $template, '192.0.2.130'); + } + fv_nbar_packet(pack('nnNNNN', 9, 1, 100000, time(), 1, 1000) . $data, '192.0.2.130'); + expect($GLOBALS['__test_db_calls'])->toBe([]); + fv_nbar_packet(pack('nnNNNN', 9, 1, 100000, time(), 1, 1069) . $data, '192.0.2.130'); + expect($GLOBALS['__test_db_calls'])->not->toBe([]); +}); diff --git a/tests/Unit/NbarRenderingTest.php b/tests/Unit/NbarRenderingTest.php new file mode 100644 index 0000000..cdc2b54 --- /dev/null +++ b/tests/Unit/NbarRenderingTest.php @@ -0,0 +1,157 @@ + time()]); + plugin_test_queue_db_result('db_fetch_row_prepared', ['minute' => time() - 600]); + plugin_test_queue_db_result('db_fetch_row_prepared', []); + ob_start(); + fv_nbar_feed_status([ + ['id' => 1, 'name' => ''], ['id' => 2, 'name' => 'Second'], + ['id' => 3, 'name' => 'Third'], ['id' => 4, 'name' => 'Excluded'], + ], [1, 2, 3]); + $html = ob_get_clean(); + expect($html)->toContain('<router>: Receiving', 'Second: No recent NBAR', 'Third: No recent NBAR', 'No retained NBAR records'); + expect($html)->not->toContain('', 'Excluded'); + expect(substr_count($html, 'fa-check-circle'))->toBe(1); + expect(substr_count($html, 'fa-times-circle'))->toBe(2); + expect($GLOBALS['__test_db_calls'])->toHaveCount(3); +}); + +it('renders an explicit empty status when no capture is selected', function () { + ob_start(); + fv_nbar_feed_status([['id' => 1, 'name' => 'Router']], []); + expect(ob_get_clean())->toContain('No captures selected'); + expect($GLOBALS['__test_db_calls'])->toBe([]); +}); + +it('does not mark future-dated records as a receiving feed', function () { + plugin_test_queue_db_result('db_fetch_row_prepared', ['minute' => time() + 600]); + ob_start(); + fv_nbar_feed_status([['id' => 1, 'name' => 'Router']], [1]); + expect(ob_get_clean())->toContain('No recent NBAR'); +}); + +it('shows the top five application totals plus Other without exposing application markup', function () { + plugin_test_queue_db_result('db_fetch_assoc_prepared', [ + ['name' => '', 'bytes' => 2000000000], + ['name' => 'Two', 'bytes' => 4000000], ['name' => 'Three', 'bytes' => 3000000], + ['name' => 'Four', 'bytes' => 2000000], ['name' => 'Five', 'bytes' => 1000000], + ['name' => 'Six', 'bytes' => 500000], ['name' => 'Zero', 'bytes' => 0], + ]); + ob_start(); + fv_nbar_chart('d.listener_id=?', [1, 1800000000, 1800001800], 'all'); + $html = ob_get_clean(); + expect($html)->toContain('<script>', '2.00 GB', 'Other', '0.50 MB', 'conic-gradient'); + expect($html)->not->toContain('