From c159d8e2c9b27a56c5868c2fd7e6a46f8ce75f24 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:16:12 +1030 Subject: [PATCH 01/27] Fix explicit nullable DNS constructors for PHP 8.4 --- CHANGELOG.md | 2 + Net/DNS2.php | 2 +- Net/DNS2/Exception.php | 4 +- Net/DNS2/Header.php | 2 +- Net/DNS2/Notifier.php | 2 +- Net/DNS2/Question.php | 2 +- Net/DNS2/RR.php | 2 +- Net/DNS2/RR/CERT.php | 2 +- Net/DNS2/RR/OPT.php | 2 +- Net/DNS2/Resolver.php | 2 +- Net/DNS2/Updater.php | 2 +- phpunit.xml | 10 +++++ tests/Unit/DnsNullableConstructorsTest.php | 39 +++++++++++++++++ tests/fixtures/dns-nullable-constructors.php | 45 ++++++++++++++++++++ 14 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 tests/Unit/DnsNullableConstructorsTest.php create mode 100644 tests/fixtures/dns-nullable-constructors.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca11bc9..823dc8d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ --- 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 diff --git a/Net/DNS2.php b/Net/DNS2.php index 7494f8d1..93b77045 100644 --- a/Net/DNS2.php +++ b/Net/DNS2.php @@ -229,7 +229,7 @@ class Net_DNS2 * @access public * */ - public function __construct(array $options = null) + public function __construct(?array $options = null) { // // load any options that were provided diff --git a/Net/DNS2/Exception.php b/Net/DNS2/Exception.php index d358f963..ab5cd049 100644 --- a/Net/DNS2/Exception.php +++ b/Net/DNS2/Exception.php @@ -44,8 +44,8 @@ public function __construct( $message = '', $code = 0, $previous = null, - Net_DNS2_Packet_Request $request = null, - Net_DNS2_Packet_Response $response = null + ?Net_DNS2_Packet_Request $request = null, + ?Net_DNS2_Packet_Response $response = null ) { // // store the request/response objects (if passed) diff --git a/Net/DNS2/Header.php b/Net/DNS2/Header.php index be202e5d..31c33391 100644 --- a/Net/DNS2/Header.php +++ b/Net/DNS2/Header.php @@ -70,7 +70,7 @@ class Net_DNS2_Header * @access public * */ - public function __construct(Net_DNS2_Packet &$packet = null) + public function __construct(?Net_DNS2_Packet &$packet = null) { if (!is_null($packet)) { diff --git a/Net/DNS2/Notifier.php b/Net/DNS2/Notifier.php index 1a888a56..12228ee7 100644 --- a/Net/DNS2/Notifier.php +++ b/Net/DNS2/Notifier.php @@ -48,7 +48,7 @@ class Net_DNS2_Notifier extends Net_DNS2 * @access public * */ - public function __construct($zone, array $options = null) + public function __construct($zone, ?array $options = null) { parent::__construct($options); diff --git a/Net/DNS2/Question.php b/Net/DNS2/Question.php index d6267285..d8fdc956 100644 --- a/Net/DNS2/Question.php +++ b/Net/DNS2/Question.php @@ -74,7 +74,7 @@ class Net_DNS2_Question * @access public * */ - public function __construct(Net_DNS2_Packet &$packet = null) + public function __construct(?Net_DNS2_Packet &$packet = null) { if (!is_null($packet)) { diff --git a/Net/DNS2/RR.php b/Net/DNS2/RR.php index 3e7f81ea..611159fd 100644 --- a/Net/DNS2/RR.php +++ b/Net/DNS2/RR.php @@ -140,7 +140,7 @@ abstract protected function rrGet(Net_DNS2_Packet &$packet); * @access public * */ - public function __construct(Net_DNS2_Packet &$packet = null, array $rr = null) + public function __construct(?Net_DNS2_Packet &$packet = null, ?array $rr = null) { if ( (!is_null($packet)) && (!is_null($rr)) ) { diff --git a/Net/DNS2/RR/CERT.php b/Net/DNS2/RR/CERT.php index e3b2f776..99b4ef14 100644 --- a/Net/DNS2/RR/CERT.php +++ b/Net/DNS2/RR/CERT.php @@ -92,7 +92,7 @@ class Net_DNS2_RR_CERT extends Net_DNS2_RR * @param array $rr a array with parsed RR values * */ - public function __construct(Net_DNS2_Packet &$packet = null, array $rr = null) + public function __construct(?Net_DNS2_Packet &$packet = null, ?array $rr = null) { parent::__construct($packet, $rr); diff --git a/Net/DNS2/RR/OPT.php b/Net/DNS2/RR/OPT.php index 615d1ae4..03eaf75f 100644 --- a/Net/DNS2/RR/OPT.php +++ b/Net/DNS2/RR/OPT.php @@ -82,7 +82,7 @@ class Net_DNS2_RR_OPT extends Net_DNS2_RR * @access public * */ - public function __construct(Net_DNS2_Packet &$packet = null, array $rr = null) + public function __construct(?Net_DNS2_Packet &$packet = null, ?array $rr = null) { // // this is for when we're manually building an OPT RR object; we aren't diff --git a/Net/DNS2/Resolver.php b/Net/DNS2/Resolver.php index 825516d4..00a1a97d 100644 --- a/Net/DNS2/Resolver.php +++ b/Net/DNS2/Resolver.php @@ -32,7 +32,7 @@ class Net_DNS2_Resolver extends Net_DNS2 * @access public * */ - public function __construct(array $options = null) + public function __construct(?array $options = null) { parent::__construct($options); } diff --git a/Net/DNS2/Updater.php b/Net/DNS2/Updater.php index b5b9820b..b875a981 100644 --- a/Net/DNS2/Updater.php +++ b/Net/DNS2/Updater.php @@ -49,7 +49,7 @@ class Net_DNS2_Updater extends Net_DNS2 * @access public * */ - public function __construct($zone, array $options = null) + public function __construct($zone, ?array $options = null) { parent::__construct($options); diff --git a/phpunit.xml b/phpunit.xml index c9c76939..2ec8407a 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -27,6 +27,16 @@ --> + Net/DNS2.php + Net/DNS2/Exception.php + Net/DNS2/Header.php + Net/DNS2/Notifier.php + Net/DNS2/Question.php + Net/DNS2/RR.php + Net/DNS2/RR/CERT.php + Net/DNS2/RR/OPT.php + Net/DNS2/Resolver.php + Net/DNS2/Updater.php includes/functions.php includes/database.php diff --git a/tests/Unit/DnsNullableConstructorsTest.php b/tests/Unit/DnsNullableConstructorsTest.php new file mode 100644 index 00000000..b353ceb8 --- /dev/null +++ b/tests/Unit/DnsNullableConstructorsTest.php @@ -0,0 +1,39 @@ + ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + expect(is_resource($process))->toBeTrue(); + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + expect(proc_close($process))->toBe(0, $stderr); + expect($stdout)->toBe('')->and($stderr)->toBe(''); + $result = json_decode(file_get_contents($directory . '/result.json'), true, 512, JSON_THROW_ON_ERROR); + expect($result['objects'])->toBe(12)->and($result['timeout'])->toBe(2); + expect($result['nullable'])->not->toBeEmpty(); + foreach ($result['nullable'] as $name => $allowsNull) { + expect($allowsNull)->toBeTrue($name); + } + $coverage = \PHPUnit\Runner\CodeCoverage::instance(); + if ($result['coverage'] && $coverage->isActive()) { + $coverage->codeCoverage()->append(\SebastianBergmann\CodeCoverage\Data\RawCodeCoverageData::fromXdebugWithoutPathCoverage($result['coverage'])); + } + } finally { + foreach (glob($directory . '/*') as $file) { + unlink($file); + } + rmdir($directory); + } +}); diff --git a/tests/fixtures/dns-nullable-constructors.php b/tests/fixtures/dns-nullable-constructors.php new file mode 100644 index 00000000..c0369ba4 --- /dev/null +++ b/tests/fixtures/dns-nullable-constructors.php @@ -0,0 +1,45 @@ +getConstructor()->getParameters() as $parameter) { + if ($parameter->isDefaultValueAvailable() && $parameter->getDefaultValue() === null && $parameter->hasType()) { + $nullable[$class . ':' . $parameter->getName()] = $parameter->allowsNull(); + } + } +} +$packet = null; +$objects = [ + new Net_DNS2(), new Net_DNS2(null), new Net_DNS2(['timeout' => 2]), + new Net_DNS2_Resolver(), new Net_DNS2_Resolver(null), + new Net_DNS2_Updater('example.invalid', null), + new Net_DNS2_Notifier('example.invalid', null), + new Net_DNS2_Exception('fixture', 0, null, null, null), + new Net_DNS2_Header($packet), new Net_DNS2_Question($packet), + new Net_DNS2_RR_OPT($packet, null), new Net_DNS2_RR_CERT($packet, null) +]; +$result = ['nullable' => $nullable, 'objects' => count($objects), 'timeout' => $objects[2]->timeout, 'coverage' => []]; +if ($driver) { + $result['coverage'] = xdebug_get_code_coverage(); + xdebug_stop_code_coverage(); +} +file_put_contents($argv[1], json_encode($result, JSON_THROW_ON_ERROR)); From 2a26a05e9336541d15f19b0965360880def69702 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:17:22 +1030 Subject: [PATCH 02/27] Add Cisco NBAR application reports and shared history summaries --- CHANGELOG.md | 3 + README.md | 72 ++++ css/nbar.css | 237 +++++++++++ flow_collector.php | 10 + flowview.php | 22 + flowview_sections.php | 137 +++++++ flowview_upgrade.php | 9 +- includes/nbar_database.php | 129 ++++++ js/nbar-preferences.js | 57 +++ js/nbar-report.js | 27 ++ js/nbar-setup-guide.js | 2 + js/nbar-tables.js | 87 ++++ locales/po/cacti.pot | 514 +++++++++++++++++++++++- manifest.json | 18 + nbar_chart.php | 79 ++++ nbar_collector.php | 166 ++++++++ nbar_common.php | 79 ++++ nbar_connections.php | 365 +++++++++++++++++ nbar_conversations.php | 88 ++++ nbar_count.php | 159 ++++++++ nbar_daily.php | 287 +++++++++++++ nbar_dns.php | 101 +++++ nbar_feed_status.php | 58 +++ nbar_preferences.php | 178 ++++++++ nbar_query.php | 88 ++++ nbar_report.php | 373 +++++++++++++++++ nbar_seen.php | 209 ++++++++++ nbar_setup_guide.php | 98 +++++ nbar_summary.php | 229 +++++++++++ nbar_timespan.php | 76 ++++ phpunit.xml | 18 + setup.php | 35 +- tests/Unit/CspNonceTest.php | 12 +- tests/Unit/NbarBackfillTest.php | 88 ++++ tests/Unit/NbarBehaviorTest.php | 168 ++++++++ tests/Unit/NbarBoundaryTest.php | 71 ++++ tests/Unit/NbarConnectionPagingTest.php | 112 ++++++ tests/Unit/NbarCountRequestTest.php | 65 +++ tests/Unit/NbarDailyWorkerTest.php | 127 ++++++ tests/Unit/NbarDirectRequestTest.php | 46 +++ tests/Unit/NbarDnsTest.php | 73 ++++ tests/Unit/NbarFailurePathsTest.php | 110 +++++ tests/Unit/NbarMaintenanceTest.php | 195 +++++++++ tests/Unit/NbarPacketTest.php | 71 ++++ tests/Unit/NbarRenderingTest.php | 145 +++++++ tests/Unit/NbarReportPageTest.php | 71 ++++ tests/Unit/NbarReportRequestTest.php | 58 +++ tests/Unit/NbarSchemaTest.php | 84 ++++ tests/Unit/NbarSessionCacheTest.php | 109 +++++ tests/bootstrap-unit.php | 58 ++- tests/fixtures/nbar-count-request.php | 61 +++ tests/fixtures/nbar-direct-request.php | 49 +++ tests/fixtures/nbar-report-request.php | 136 +++++++ 53 files changed, 5891 insertions(+), 28 deletions(-) create mode 100644 css/nbar.css create mode 100644 flowview_sections.php create mode 100644 includes/nbar_database.php create mode 100644 js/nbar-preferences.js create mode 100644 js/nbar-report.js create mode 100644 js/nbar-setup-guide.js create mode 100644 js/nbar-tables.js create mode 100644 nbar_chart.php create mode 100644 nbar_collector.php create mode 100644 nbar_common.php create mode 100644 nbar_connections.php create mode 100644 nbar_conversations.php create mode 100644 nbar_count.php create mode 100644 nbar_daily.php create mode 100644 nbar_dns.php create mode 100644 nbar_feed_status.php create mode 100644 nbar_preferences.php create mode 100644 nbar_query.php create mode 100644 nbar_report.php create mode 100644 nbar_seen.php create mode 100644 nbar_setup_guide.php create mode 100644 nbar_summary.php create mode 100644 nbar_timespan.php create mode 100644 tests/Unit/NbarBackfillTest.php create mode 100644 tests/Unit/NbarBehaviorTest.php create mode 100644 tests/Unit/NbarBoundaryTest.php create mode 100644 tests/Unit/NbarConnectionPagingTest.php create mode 100644 tests/Unit/NbarCountRequestTest.php create mode 100644 tests/Unit/NbarDailyWorkerTest.php create mode 100644 tests/Unit/NbarDirectRequestTest.php create mode 100644 tests/Unit/NbarDnsTest.php create mode 100644 tests/Unit/NbarFailurePathsTest.php create mode 100644 tests/Unit/NbarMaintenanceTest.php create mode 100644 tests/Unit/NbarPacketTest.php create mode 100644 tests/Unit/NbarRenderingTest.php create mode 100644 tests/Unit/NbarReportPageTest.php create mode 100644 tests/Unit/NbarReportRequestTest.php create mode 100644 tests/Unit/NbarSchemaTest.php create mode 100644 tests/Unit/NbarSessionCacheTest.php create mode 100644 tests/fixtures/nbar-count-request.php create mode 100644 tests/fixtures/nbar-direct-request.php create mode 100644 tests/fixtures/nbar-report-request.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 823dc8d0..e9bede4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ * fix: Make bundled Net_DNS2 constructor nullability explicit for PHP 8.4 without changing accepted arguments +* feature: Add Cisco Apps (NBAR) reports for application-bearing NetFlow v9 exports, with IPv4/IPv6 application totals, conversations, paged connections, per-account capture/network options, recent-record status, first/last observation dates and a router setup guide +* feature: Maintain reusable per-capture NBAR hourly/daily summaries through the existing Cacti poller, with raw partial-boundary fallback and a separate retention setting capped at 30 days + * 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 diff --git a/README.md b/README.md index 8de558ce..3977ede4 100644 --- a/README.md +++ b/README.md @@ -209,5 +209,77 @@ an issue. Got any ideas or complaints, please see the Cacti forums or GitHub for a resolution. + +## Cisco Apps (NBAR) + +Cisco Apps is a separate application report within FlowView. The current NBAR +integration reads application IDs and names from NetFlow v9 data and options +templates, including IPv4 and IPv6 records. IPFIX application decoding is not +implemented by this integration. Existing FlowView reports retain their normal +collector and report paths. + +Configure a FlowView listener for each export stream and configure the router to +export application-bearing records to it. The report's Setup guide contains a +Cisco 1100 Series example with placeholders for the collector, UDP port and router +interfaces. Check Cisco documentation for the exact platform and IOS XE release. +Creating a listener alone does not enable NBAR on the router. + +The report offers Top applications, Conversations and Connections. Filter by IP +matches either endpoint. Conversations requires the account's Internal Networks +settings to identify hosts and upload/download direction. Combine Options accepts +two or three captures; leave them all unselected to save networks without combining. +Choose one observation point per traffic path: overlapping LAN/WAN captures add +both copies and can overcount traffic. + +STATUS describes recent retained application records for each selected capture, +independently of historical report filters. A quiet capture can show no recent +NBAR despite a working exporter. It is not a connectivity test. + +First seen and Last seen are the earliest/latest retained observation minutes in +the selected period. They do not prove continuous activity, or give exact connection +start/end times. Missing dates are shown as Unavailable. Average bandwidth divides +data volume by the full selected period. Column sorting applies to the displayed +page; resizing is saved in that browser. + +NBAR History Retention is separate from the existing FlowView Data Retention Policy. +It offers 1, 3, 7, 14 or 30 days, with a seven-day default. Timespan choices and custom +ranges respect this setting. The existing Cacti poller maintains summaries and +performs bounded cleanup; no extra scheduled task is required. A shorter retention +setting cannot be undone to recover deleted history. If polling stops, maintenance +and cleanup stop too. Shared maintenance locks are scoped to the FlowView database. + +## Cisco Apps (NBAR) historical Connections + +Connections reads the automatically maintained daily history for each selected capture. +Opening a report or changing its IP filter does not queue a new complete connection build. +The normal FlowView poller maintains this history; no additional cron job is required. + +Pages contain up to 100 connections, ordered by total data volume across the selected +captures and period. Indexed candidates are checked against every selected daily segment +so byte, packet and record totals remain exact. Partial days use hourly or raw data. +Previous, Next and a numeric page field provide navigation without a large dropdown or +an expensive full-result count. Very deep pages or broadly tied results can still require +more work; a shorter period or IP filter reduces that work. + +A small account-scoped session cache holds nearby pages for 120 seconds for past periods +and 30 seconds for periods including current data. This is separate from retained history. +The existing authenticated FlowView realm controls access; capture selections and filters are included in cache keys. + +## Reusable NBAR daily summaries + +Top Applications, Conversations and Connections share daily summaries built automatically +per capture, independently of report filters and Cacti accounts. Calendar days run from +local midnight to the following midnight in the Cacti server timezone, including daylight +saving changes. Completed hours are added to today's summary in bounded transactions. +Completed days are reused; late exports invalidate affected summaries and trigger a rebuild. +Partial days and unpublished or changed intervals fall back to hourly summaries or raw records. +IP, application and IP-version filters are applied to the preserved summary fields. + +Daily history follows Cisco Apps (NBAR) History Retention, currently selectable up to +30 days. Retention is a rolling data window, not a new retention period beginning each time +a report is opened. Daily summaries are shared source data and are reused for different +periods, filters and users, subject to listener permissions. Connections reads pages from +this history rather than preparing another full copy for every selection. + ----------------------------------------------- Copyright (c) 2004-2026 - The Cacti Group, Inc. diff --git a/css/nbar.css b/css/nbar.css new file mode 100644 index 00000000..1440383b --- /dev/null +++ b/css/nbar.css @@ -0,0 +1,237 @@ +/* Copyright (C) 2004-2026 The Cacti Group. GPL-2.0-or-later. */ +#nbarTable th .ui-resizable-handle,#nbarConversationTable th .ui-resizable-handle { + display:none!important; + pointer-events:none!important} +.flowview-nbar-table-wrap { + position:relative; + width:100%; + max-width:100%; + min-width:0; + /* Saved column widths must scroll here instead of widening the report bar. */ + contain:inline-size; + overflow-x:auto; + display:block} +.flowview-nbar-resize { + display:block!important; + z-index:10} +.flowview-nbar-resize .tablesorter-resizable-handle { + width:12px!important; + pointer-events:auto} +.flowview-nbar-resize .tablesorter-resizable-handle:before { + content:""; + position:absolute; + top:6px; + left:4px; + height:14px; + border-left:1px solid currentColor; + border-right:1px solid currentColor; + width:2px; + opacity:.35} +.flowview-nbar-resize .tablesorter-resizable-handle:hover { + background:rgba(127,127,127,.25)} +.nbarSettingsRow { + display:flex; + flex-wrap:wrap; + align-items:flex-start; + gap:8px 12px; + padding:4px} +.nbarSetting { + display:flex; + align-items:center; + gap:6px} +.nbarSettingsRow>details { + max-width:100%; + min-width:0} +.nbarSettingsRow>details>div { + width:400px; + max-width:100%; + box-sizing:border-box; + white-space:normal; + overflow-wrap:anywhere} +.nbarSettingsRow textarea { + width:100%; + max-width:100%; + box-sizing:border-box} +.nbarSettingsRow summary { + cursor:pointer} +.nbarChart { + max-width:100%} +.nbarReportBar { + display:flex; + flex-wrap:wrap; + align-items:flex-start; + gap:12px} +.nbarReportBar>form { + flex:1 1 660px; + min-width:0; + margin:0} +.nbarChart { + flex:0 1 340px; + padding:4px 12px; + box-sizing:border-box; + margin-right:40px} +.nbarChartBody { + display:flex; + align-items:center; + gap:14px; + margin-top:6px} +.nbarChartPie { + width:112px; + height:112px; + border-radius:50%; + flex-shrink:0} +.nbarChart ul { + list-style:none; + margin:0; + padding:0; + flex:1; + min-width:0} +.nbarChart li { + display:flex; + align-items:center; + gap:6px; + padding:0; + line-height:1.35; + white-space:nowrap} +.nbarChartKey { + width:9px; + height:9px; + flex-shrink:0} +.nbarChartName { + flex:1; + overflow:hidden; + text-overflow:ellipsis} +@media(max-width:1100px) { + .nbarChart { + flex-basis:100%; + padding-left:4px; + margin-right:0} +.nbarChartBody { + max-width:340px} +} +#nbarTable,#nbarConversationTable { + table-layout:fixed; + width:100%} +#nbarTable th,#nbarTable td,#nbarConversationTable th,#nbarConversationTable td { + white-space:normal!important; + overflow-wrap:anywhere} +#nbarTable th .textSubHeaderDark,#nbarConversationTable th .textSubHeaderDark { + white-space:normal!important} +#nbarCombineDialog { + width:min(640px,90vw); + max-height:85vh; + padding:0; + position:fixed; + margin:auto; + box-sizing:border-box; + overflow:hidden; + font-weight:normal; + } +#nbarCombineDialog::backdrop { + background:rgba(0,0,0,.4); + } +#nbarCombineDialog .nbarCombineHeader { + display:flex; + justify-content:space-between; + align-items:center; + gap:16px; + padding:12px 16px; + } +#nbarCombineDialog .nbarCombineBody { + padding:16px; + max-height:calc(85vh - 75px); + overflow:auto; + box-sizing:border-box; + font-weight:normal; + line-height:1.45; + } +#nbarCombineDialog textarea { + width:100%; + box-sizing:border-box; + max-width:100%; + } +#nbarCombineDialog h3 { + margin:0 0 8px; + } +#nbarCombineDialog p,#nbarCombineDialog small { + font-weight:normal; + font-size:inherit; + } +#nbarSetupGuide { + width:min(900px,90vw); + max-width:90vw; + max-height:85vh; + padding:0; + position:fixed; + margin:auto; + box-sizing:border-box; + overflow:hidden; + font-weight:normal; + } +#nbarSetupGuide::backdrop { + background:rgba(0,0,0,.4); + } +#nbarSetupGuide .nbarGuideHeader { + display:flex; + justify-content:space-between; + align-items:center; + gap:16px; + padding:12px 16px; + box-sizing:border-box; + } +#nbarSetupGuide .nbarGuideBody { + padding:16px; + max-height:calc(85vh - 75px); + overflow-y:auto; + overflow-x:hidden; + box-sizing:border-box; + min-width:0; + font-weight:normal; + line-height:1.45; + } +#nbarSetupGuide .nbarGuideBody p,#nbarSetupGuide .nbarGuideBody small { + font-weight:normal; + } +#nbarSetupGuide h3 { + font-size:1.15em; + margin:0 0 .8em; + font-weight:bold; + } +#nbarSetupGuide h3:not(:first-child) { + margin-top:1.3em; + } +#nbarSetupGuide p { + margin:0 0 1em; + overflow-wrap:anywhere; + } +#nbarSetupGuide pre { + padding:12px; + width:100%; + max-width:100%; + box-sizing:border-box; + overflow:auto; + white-space:pre; + font-weight:normal; + font-family:monospace; + line-height:1.3; + } +#nbarSetupGuide .nbarGuideHeader button { + flex-shrink:0; + } +#fv_section_tabs { + padding-bottom:0} +#fv_section_tabs + table { + margin-top:0} +#fv_section_tabs a[aria-current="page"] { + font-weight:bold; + text-decoration:none; + } +#fv_section_tabs a:focus-visible { + outline:2px solid currentColor; + outline-offset:2px; + } + +/* Use the theme foreground while retaining the surrounding report text size. */ +.nbarReportText { + font-size:inherit; + font-weight:normal} diff --git a/flow_collector.php b/flow_collector.php index 0759e8af..5a271e69 100644 --- a/flow_collector.php +++ b/flow_collector.php @@ -1275,6 +1275,8 @@ function process_fv9($p, $ex_addr): void { global $templates, $tlengths, $allfields, $pacmap, $listener_id; flowview_connect(); + require_once __DIR__ . '/nbar_collector.php'; + fv_nbar_packet($p, $ex_addr); if (!isset($templates[$ex_addr])) { $templates[$ex_addr] = []; @@ -1422,6 +1424,10 @@ function process_fv9($p, $ex_addr): void { $field = substr($p, $h, $t['length']); + // Decode Cisco reduced-length three-byte unsigned integers. + if ($t['unpack'] === 'N' && $t['length'] == 3 && strlen($field) == 3) { + $field = chr(0) . $field; + } $field = unpack($t['unpack'], $field) ?: []; if ($t['pack'] == 'ipv4Address') { @@ -1729,6 +1735,10 @@ function process_fv10($p, $ex_addr): void { $id = $t['field_id']; $field = substr($p, $h, $t['length']); + // Decode Cisco reduced-length three-byte unsigned integers. + if ($t['unpack'] === 'N' && $t['length'] == 3 && strlen($field) == 3) { + $field = chr(0) . $field; + } $field = unpack($t['unpack'], $field) ?: []; if ($t['pack'] == 'ipv4Address') { diff --git a/flowview.php b/flowview.php index 0a2a903a..856b74a8 100644 --- a/flowview.php +++ b/flowview.php @@ -34,6 +34,17 @@ require_once($config['base_path'] . '/lib/time.php'); flowview_connect(); +require_once __DIR__ . '/flowview_sections.php'; +if (get_nfilter_request_var('action') === 'nbar_count') { + require_once __DIR__ . '/nbar_count.php'; + fv_nbar_count_request(); +} +if (get_nfilter_request_var('action') === 'applications' || + (get_nfilter_request_var('action') === 'view' && get_nfilter_request_var('report') === 'nbar')) { + general_header(); + include __DIR__ . '/nbar_report.php'; + exit; +} set_default_action(); @@ -78,6 +89,15 @@ break; case 'gettimespan': + // The NBAR retention preset is not a core Cacti graph timespan. + if (get_nfilter_request_var('report') === 'nbar' && (int) get_filter_request_var('predefined_timespan') === 1000) { + require_once __DIR__ . '/nbar_common.php'; + $end = (int) (floor(time() / 60) * 60); + $start = $end - fv_nbar_retention_days() * 86400; + header('Content-Type: application/json'); + 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]); + exit; + } flowview_request_vars(); flowview_gettimespan(); @@ -88,6 +108,8 @@ default: general_header(); + fv_section_save('flows', fv_section_request_params()); + fv_section_navigation('flows'); flowview_request_vars(); $title = load_session_for_filter(); diff --git a/flowview_sections.php b/flowview_sections.php new file mode 100644 index 00000000..878a65db --- /dev/null +++ b/flowview_sections.php @@ -0,0 +1,137 @@ + $v) { + if (!is_scalar($v)) { + unset($p[$k]); + } + } + $p['predefined_timespan'] = 1; // Re-enter reports with a fresh half-hour window. + if ((int) ($p['predefined_timespan'] ?? 0) > 0) { + unset($p['date1'],$p['date2']); + $c = json_decode($p['context'] ?? '', true); + if (is_array($c)) { + unset($c['date1'],$c['date2']); + $p['context'] = json_encode($c); + } + } + $p['action'] = 'view'; + $p['section'] = $section; + if ($section === 'nbar') { + $p['report'] = 'nbar'; + } elseif (($p['report'] ?? '') === 'nbar') { + unset($p['report']); + } + return $p; +} +/** + * Build a local URL restoring one report section. + * + * @param string $section Report section identifier. + * + * @return string + */ +function fv_section_url($section) { + return 'flowview.php?' . http_build_query(fv_section_params($section)); +} +/** + * Persist account-specific section navigation without drilldown addresses. + * + * @param string $section Report section identifier. + * @param array $params Scalar navigation fields. + * + * @return void + */ +function fv_section_save($section, $params) { + unset($params['app'],$params['internal'],$params['external'],$params['section']); + $value = json_encode($params); + if (read_user_setting('flowview_last_section', '', true) !== $section) { + set_user_setting('flowview_last_section', $section); + } + if (read_user_setting('flowview_section_' . $section, '', true) !== $value) { + set_user_setting('flowview_section_' . $section, $value); + } +} +/** + * Render theme-aware navigation between Flow Reports and Cisco Apps. + * + * @param string $selected Currently selected report section. + * + * @return void + */ +function fv_section_navigation($selected) { + global $config; + echo ''; + echo ''; +} +$section_entry = array_diff_key(fv_section_request_params(), array_flip(['headercontent'])); +if (($_SERVER['REQUEST_METHOD'] ?? '') === 'GET' && (!$section_entry || (cacti_sizeof($section_entry) === 1 && ($section_entry['action'] ?? '') === 'view'))) { + $last = read_user_setting('flowview_last_section', '', true); + if (in_array($last, ['flows', 'nbar'], true)) { + $restore = fv_section_url($last); + if (isset_request_var('headercontent')) { + $restore .= '&headercontent=true'; + } + header('Location: ' . $restore); + exit; + } +} +if (get_nfilter_request_var('section') === 'nbar') { + set_request_var('action', 'view'); + set_request_var('report', 'nbar'); +} diff --git a/flowview_upgrade.php b/flowview_upgrade.php index a0e82246..d6abfc48 100644 --- a/flowview_upgrade.php +++ b/flowview_upgrade.php @@ -82,7 +82,7 @@ $current_version = $info['version']; /* do a version check */ -if ($forcever == '' && $old_version == $current_version) { +if ($forcever == '' && $old_version == $current_version && (int) read_config_option('flowview_nbar_schema_version') >= 1) { cacti_log('Your Flowview is already up to date (v' . $current_version . ' vs v' . $old_version . ') not upgrading. Use --forcever to override', true, 'FLOWVIEW'); exit(0); } @@ -119,6 +119,13 @@ * @return void */ function flowview_upgrade($current, $old): void { + require_once __DIR__ . '/nbar_common.php'; + if (!fv_nbar_schema_install()) { + throw new RuntimeException('Unable to upgrade NBAR history schema'); + } + set_config_option('flowview_nbar_schema_version', '1'); + + global $config, $info; if ($current != $old) { diff --git a/includes/nbar_database.php b/includes/nbar_database.php new file mode 100644 index 00000000..84c4c5f6 --- /dev/null +++ b/includes/nbar_database.php @@ -0,0 +1,129 @@ + ['type' => 'int', 'unsigned' => true], + 'minute' => ['type' => 'int', 'unsigned' => true], + 'hour' => ['type' => 'int', 'unsigned' => true], + 'app_id' => ['type' => 'varchar(32)'], + 'src' => ['type' => 'varbinary(16)'], + 'dst' => ['type' => 'varbinary(16)'], + 'sport' => ['type' => 'smallint', 'unsigned' => true], + 'dport' => ['type' => 'smallint', 'unsigned' => true], + 'protocol' => ['type' => 'tinyint', 'unsigned' => true], + 'family' => ['type' => 'tinyint', 'unsigned' => true], + 'bytes' => ['type' => 'bigint', 'unsigned' => true], + 'packets' => ['type' => 'bigint', 'unsigned' => true], + 'flows' => ['type' => 'bigint', 'unsigned' => true], + 'first_seen' => ['type' => 'int', 'unsigned' => true, 'NULL' => true], + 'last_seen' => ['type' => 'int', 'unsigned' => true, 'NULL' => true] + ]; + $counters = ['bytes', 'packets', 'flows']; + $dates = ['first_seen', 'last_seen']; + $tuple = ['app_id', 'src', 'dst', 'sport', 'dport', 'protocol']; + $kind = str_replace('daily_', '', $suffix); + $keys = []; + $extra = []; + switch ($kind) { + case 'names': + $extra = ['router' => ['type' => 'varchar(45)'], 'name' => ['type' => 'varchar(255)'], 'updated' => ['type' => 'int', 'unsigned' => true]]; + $primary = ['router', 'app_id']; + $columns = ['router', 'app_id', 'name', 'updated']; + break; + case 'data': + $primary = array_merge(['listener_id', 'minute'], $tuple); + $columns = array_merge($primary, $counters); + $keys[] = ['name' => 'minute', 'columns' => ['minute']]; + break; + case 'hours': + $extra = ['revision' => ['type' => 'bigint', 'unsigned' => true, 'default' => 0], 'built' => ['type' => 'bigint', 'NULL' => true, 'default' => -1], 'connection_built' => ['type' => 'bigint', 'NULL' => true, 'default' => -1]]; + $primary = ['listener_id', 'hour']; + $columns = array_merge($primary, array_keys($extra)); + $keys[] = ['name' => 'hour', 'columns' => ['hour']]; + break; + case 'apps': + case 'pairs': + case 'connections': + $group = $kind === 'apps' ? ['app_id', 'family'] : ($kind === 'pairs' ? ['app_id', 'src', 'dst'] : $tuple); + $primary = array_merge(['listener_id', 'hour'], $group); + $columns = array_merge($primary, $counters, $dates); + $keys[] = ['name' => 'hour', 'columns' => ['hour']]; + if ($suffix === 'daily_connections') { + $keys[] = ['name' => 'capture_day_bytes', 'columns' => ['listener_id', 'hour', 'bytes']]; + } + break; + case 'days': + $extra = ['day' => ['type' => 'int', 'unsigned' => true], 'covered_to' => ['type' => 'int', 'unsigned' => true], 'revision' => ['type' => 'decimal(30,0)'], 'timezone' => ['type' => 'varchar(64) CHARACTER SET ascii'], 'updated' => ['type' => 'int', 'unsigned' => true]]; + $primary = ['listener_id', 'day']; + $columns = array_merge(['listener_id'], array_keys($extra)); + $keys[] = ['name' => 'day', 'columns' => ['day']]; + break; + case 'seen_upgrade': + $extra = ['kind' => ['type' => 'tinyint', 'unsigned' => true], 'period' => ['type' => 'int', 'unsigned' => true], 'done' => ['type' => 'tinyint', 'unsigned' => true, 'default' => 0], 'phase' => ['type' => 'tinyint', 'unsigned' => true, 'default' => 0], 'app_cursor' => ['type' => 'varchar(32)', 'NULL' => true]]; + $primary = ['kind', 'listener_id', 'period']; + $columns = ['kind', 'listener_id', 'period', 'done', 'phase', 'app_cursor']; + $keys[] = ['name' => 'pending', 'columns' => ['done', 'kind', 'period']]; + break; + default: + throw new InvalidArgumentException('Unknown NBAR schema'); + } + if (!in_array($suffix, ['names', 'data', 'hours', 'apps', 'pairs', 'connections', 'daily_apps', 'daily_pairs', 'daily_connections', 'days', 'seen_upgrade'], true)) { + throw new InvalidArgumentException('Unknown NBAR schema'); + } + $fields = array_merge($fields, $extra); + $definition = ['columns' => [], 'primary' => implode('`,`', $primary), 'keys' => $keys, 'type' => 'InnoDB']; + foreach ($columns as $name) { + $definition['columns'][] = array_merge(['name' => $name, 'NULL' => false], $fields[$name]); + } + return $definition; +} + +/** + * Create a table in the selected FlowView database and verify its presence. + * + * @param string $suffix Validated NBAR table suffix. + * + * @return bool + */ +function fv_nbar_schema_table($suffix) { + $definition = fv_nbar_schema_definition($suffix); + $table = 'plugin_flowview_nbar_' . $suffix; + // Core plugin APIs use Cacti's connection. FlowView's equivalent also + // records plugin ownership, while supporting its separate flow database. + flowview_db_table_create($table, $definition); + return flowview_db_fetch_cell("SHOW TABLES LIKE '$table'") === $table; +} diff --git a/js/nbar-preferences.js b/js/nbar-preferences.js new file mode 100644 index 00000000..a7cc1672 --- /dev/null +++ b/js/nbar-preferences.js @@ -0,0 +1,57 @@ +/* Copyright (C) 2004-2026 The Cacti Group. GPL-2.0-or-later. */ + +$(function(){ + var prefs=window.fvNbarDisplay; + var combineDialog=document.getElementById('nbarCombineDialog'), combineSnapshot; + $('#nbarCombineOptions').on('click',function(){ + combineSnapshot={prefixes:$('#nbarPrefixes').val(), picks:$('#nbarCombineDialog input[type=checkbox]').map(function(){return this.checked;}).get()}; + if(!combineDialog.open) combineDialog.showModal(); + }); + $('#nbarCombineClose,#nbarCombineCancel').on('click',function(){combineDialog.close();}); + $(combineDialog).on('close',function(){ + if(!combineSnapshot) return; + $('#nbarPrefixes').val(combineSnapshot.prefixes); + $('#nbarCombineDialog input[type=checkbox]').each(function(i){this.checked=combineSnapshot.picks[i];}); + updateRouterSelection(); + }); + + var routerPicks=$('#nbarPreferences input[name="site_listeners[]"]'); + function updateRouterSelection() { + var count=routerPicks.filter(':checked').length; + routerPicks.not(':checked').prop('disabled',count>=3); + $('#nbarRouterSelectionCount').text(prefs.labels.selection.replace('{count}',count)+(count===1?prefs.labels.single:(count===0?prefs.labels.off:''))); + $('#nbarCombineSave').prop('disabled',count===1 || count>3); + } + $('#nbarCombineSave').on('click',function(){ + var count=routerPicks.filter(':checked').length; + if(count===1 || count>3) return false; + if(count>=2) $('#nbar_filter [name=listener]').val('0'); + $('',{type:'hidden',name:'combine_apply',value:'1'}).appendTo('#nbar_filter'); + }); +routerPicks.on('change',updateRouterSelection); + updateRouterSelection(); + $('#nbarTable,#nbarConversationTable').each(function(){ + var table=$(this); + table.find('thead th').each(function(index){ + var th=$(this), label=th.text(), data=th.attr('data-nbar-unit')==='data', rate=th.attr('data-nbar-unit')==='rate', dns=th.attr('data-nbar-unit')==='dns'; + if(dns && prefs.dns==='0') { th.hide(); table.find('tbody tr').each(function(){$(this).children().eq(index).hide();}); } + if(!data && !rate) return; + var choice=data?prefs.data:prefs.rate; + th.text(label.replace(data?'GB':'Mbps',choice==='Auto'?(data?'MB / GB':'Kbps / Mbps'):choice)); + th.attr('title',rate?prefs.labels.rate:prefs.labels.data); + table.find('tbody tr').each(function(){ + var cell=$(this).children().eq(index), value=Number(cell.attr('data-text')); + if(!cell.is('[data-text]') || !Number.isFinite(value) || value<0) return; + var unit=choice, result=value; + if(data) { if(choice==='Auto') unit=value>=1?'GB':'MB'; if(unit==='MB') result=value*1000; } + else { if(choice==='Auto') unit=value>=1?'Mbps':'Kbps'; if(unit==='Kbps') result=value*1000; } + var precision=choice==='Auto'?2:(data?4:3), threshold=Math.pow(10,-precision); + var text=result>0 && result= 0 && item[0] < columns && (item[1] === 0 || item[1] === 1); + })) savedSort = saved; + } catch (error) {} + + function saveSort() { + try { + if (storage && table[0].config) { + storage.setItem(spec.sortKey, JSON.stringify(table[0].config.sortList)); + } + } catch (error) {} + } + + for (var index = firstNumber; index < columns; index++) { + headers[index] = {sorter: 'digit', sortInitialOrder: 'desc'}; + } + + var widths = spec.conversations ? [20, 20, 12, 8, 8, 8, 8, 8, 8] : + (columns === 14 ? [9, 11, 10, 5, 11, 10, 5, 5, 7, 7, 5, 5, 5, 5] : [28, 14, 18, 10, 10, 10, 10]); + table.tablesorter({ + headers: headers, + sortList: savedSort, + initialized: function (element) { + // Cacti also initializes jQuery UI handles. Use the table widget's + // handles consistently so saved widths and Safari dragging agree. + var config = element.config; + var widget = $.tablesorter.getWidgetById('resizable'); + if (!widget || !$.tablesorter.resizable) return; + config.widgetOptions = $.extend({}, widget.options, config.widgetOptions); + var options = config.widgetOptions; + $(element).removeClass('hasResizable'); + $.tablesorter.resizable.init(config, options); + config.widgets.push('resizable'); + config.widgetInit.resizable = true; + options.$resizable_container.addClass('flowview-nbar-resize').children() + .attr('title', spec.resizeLabel).each(function (index) { + var header = $(config.$headerIndexed[index]); + $(this).css('color', header.find('.textSubHeaderDark').css('color') || header.css('color')); + }); + $(element).trigger('resizableUpdate'); + }, + widgets: ['zebra'], + widgetOptions: { + resizable: true, + storage_tableId: spec.conversations ? 'flowview-nbar-conversation-columns-v2' : 'flowview-nbar-columns-v2-' + columns, + resizable_widths: widths.map(function (width) { return width + '%'; }) + }, + widgetZebra: {css: ['even', 'odd']}, + headerTemplate: '
{content} {icon}
', + cssIconAsc: 'fa-sort-up', + cssIconDesc: 'fa-sort-down', + cssIconNone: 'fa-sort', + cssIcon: 'fa' + }).on('sortEnd', saveSort); + + $('.nbarPageLink').on('click', saveSort); + $('.nbarPageJump').on('click', function () { + var input = $(this).closest('.nbarPagination').find('.nbarPageNumber')[0]; + if (input && input.checkValidity()) { + saveSort(); + location.href = this.dataset.url + '&page=' + parseInt(input.value, 10); + } + }); + $('.nbarPageNumber').on('keydown', function (event) { + if (event.key === 'Enter') { + event.preventDefault(); + $(this).closest('.nbarPagination').find('.nbarPageJump').trigger('click'); + } + }); + }); +}; diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 23efd5d2..627f4e9f 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -6,11 +6,11 @@ #, fuzzy msgid "" msgstr "" -"Project-Id-Version: Cacti \n" +"Project-Id-Version: Cacti 5.1\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-12 14:40-0400\n" +"POT-Creation-Date: 2026-10-17 22:46+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: Cacti Developers >\n" +"Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" "Language: \n" "MIME-Version: 1.0\n" @@ -480,7 +480,7 @@ msgid "Default" msgstr "" #: flowview_databases.php flowview_devices.php flowview_filters.php -#: flowview_schedules.php includes/functions.php +#: flowview_schedules.php includes/functions.php nbar_report.php msgid "Go" msgstr "" @@ -490,7 +490,7 @@ msgid "Set/Refresh Filters" msgstr "" #: flowview_databases.php flowview_devices.php flowview_filters.php -#: flowview_schedules.php includes/functions.php +#: flowview_schedules.php includes/functions.php nbar_report.php msgid "Clear" msgstr "" @@ -518,7 +518,7 @@ msgstr[0] "" msgstr[1] "" #: flowview_databases.php flowview_devices.php flowview_filters.php -#: flowview_schedules.php includes/functions.php +#: flowview_schedules.php includes/functions.php nbar_preferences.php msgid "Cancel" msgstr "" @@ -714,6 +714,7 @@ msgid "Port this collector will listen on." msgstr "" #: flowview_devices.php includes/arrays.php includes/functions.php +#: nbar_report.php msgid "Protocol" msgstr "" @@ -1240,6 +1241,18 @@ msgstr "" msgid "Yes" msgstr "" +#: flowview_sections.php +msgid "FlowView sections" +msgstr "" + +#: flowview_sections.php +msgid "Cisco Apps (NBAR)" +msgstr "" + +#: flowview_sections.php +msgid "Flow Reports" +msgstr "" + #: includes/arrays.php includes/functions.php msgid "Select a Report" msgstr "" @@ -1272,11 +1285,11 @@ msgstr "" msgid "UDP/TCP Port" msgstr "" -#: includes/arrays.php includes/functions.php +#: includes/arrays.php includes/functions.php nbar_report.php msgid "Source IP" msgstr "" -#: includes/arrays.php +#: includes/arrays.php nbar_report.php msgid "Destination IP" msgstr "" @@ -1398,7 +1411,7 @@ msgstr "" msgid "Bytes" msgstr "" -#: includes/arrays.php includes/functions.php +#: includes/arrays.php includes/functions.php nbar_report.php msgid "Packets" msgstr "" @@ -1913,7 +1926,7 @@ msgstr "" msgid "Edit the Flow Filter" msgstr "" -#: includes/functions.php +#: includes/functions.php nbar_preferences.php msgid "Save" msgstr "" @@ -1992,19 +2005,19 @@ msgstr "" msgid "Show the post-NAT (translated) IP/DNS name instead of the original values" msgstr "" -#: includes/functions.php +#: includes/functions.php nbar_report.php msgid "Timespan" msgstr "" -#: includes/functions.php +#: includes/functions.php nbar_report.php msgid "Custom" msgstr "" -#: includes/functions.php +#: includes/functions.php nbar_report.php msgid "From" msgstr "" -#: includes/functions.php +#: includes/functions.php nbar_report.php msgid "To" msgstr "" @@ -2186,7 +2199,7 @@ msgstr "" msgid "No valid report was selected. Please open a bug on GitHub" msgstr "" -#: includes/functions.php +#: includes/functions.php nbar_report.php msgid "Source DNS" msgstr "" @@ -2404,6 +2417,449 @@ msgstr "" msgid "InnoDB (Slow, High Concurrency)" msgstr "" +#: nbar_chart.php +msgid "Other" +msgstr "" + +#: nbar_chart.php +msgid "Top 5 applications" +msgstr "" + +#: nbar_chart.php +msgid "Top 5 applications by data" +msgstr "" + +#: nbar_chart.php +msgid "Application data share for the selected period" +msgstr "" + +#: nbar_chart.php +msgid "No application data in this period" +msgstr "" + +#: nbar_conversations.php +msgid "Configure your site networks in Application settings to label internal and external devices. Top applications and Connections work directly with exported IPv4 and IPv6 addresses." +msgstr "" + +#: nbar_conversations.php nbar_report.php +msgid "← Back to top applications" +msgstr "" + +#: nbar_conversations.php +msgid "Internal Host ↔ External Host. Click a device to see its individual connections. Bandwidth is averaged over the selected timespan." +msgstr "" + +#: nbar_conversations.php nbar_report.php +msgid "Application" +msgstr "" + +#: nbar_conversations.php +msgid "Download (Avg Mbps)" +msgstr "" + +#: nbar_conversations.php +msgid "Download (GB)" +msgstr "" + +#: nbar_conversations.php +msgid "External Host" +msgstr "" + +#: nbar_conversations.php nbar_report.php +msgid "First seen" +msgstr "" + +#: nbar_conversations.php +msgid "Internal Host" +msgstr "" + +#: nbar_conversations.php nbar_report.php +msgid "Last seen" +msgstr "" + +#: nbar_conversations.php +msgid "Upload (Avg Mbps)" +msgstr "" + +#: nbar_conversations.php +msgid "Upload (GB)" +msgstr "" + +#: nbar_conversations.php nbar_report.php +#, php-format +msgid "Awaiting name (%s)" +msgstr "" + +#: nbar_conversations.php +msgid "No conversations for this period. Allow 1–2 minutes for exports, or choose a longer timespan." +msgstr "" + +#: nbar_conversations.php nbar_report.php +msgid "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." +msgstr "" + +#: nbar_conversations.php nbar_report.php +msgid "Report information" +msgstr "" + +#: nbar_conversations.php nbar_report.php +msgid "Drag to resize column" +msgstr "" + +#: nbar_dns.php +msgid "No reverse DNS" +msgstr "" + +#: nbar_dns.php +msgid "Not resolved" +msgstr "" + +#: nbar_feed_status.php +msgid "STATUS:" +msgstr "" + +#: nbar_feed_status.php +msgid "No recent NBAR" +msgstr "" + +#: nbar_feed_status.php +msgid "Receiving" +msgstr "" + +#: nbar_feed_status.php +#, php-format +msgid "Last NBAR record: %s" +msgstr "" + +#: nbar_feed_status.php +msgid "No retained NBAR records" +msgstr "" + +#: nbar_feed_status.php +msgid "No captures selected" +msgstr "" + +#: nbar_preferences.php +msgid "Settings were not saved. Enter valid IPv4/IPv6 networks, or leave the list blank." +msgstr "" + +#: nbar_preferences.php +msgid "Settings were not saved. Choose two or three captures to combine, or clear all selections to save Internal Networks only." +msgstr "" + +#: nbar_preferences.php +msgid "Bandwidth units" +msgstr "" + +#: nbar_preferences.php +msgid "DNS names" +msgstr "" + +#: nbar_preferences.php +msgid "Data units" +msgstr "" + +#: nbar_preferences.php +msgid "Hide" +msgstr "" + +#: nbar_preferences.php nbar_report.php +msgid "Show" +msgstr "" + +#: nbar_preferences.php +msgid "Auto" +msgstr "" + +#: nbar_preferences.php nbar_setup_guide.php +msgid "Close" +msgstr "" + +#: nbar_preferences.php +msgid "Combine Options" +msgstr "" + +#: nbar_preferences.php nbar_report.php +msgid "Options" +msgstr "" + +#: nbar_preferences.php +msgid "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." +msgstr "" + +#: nbar_preferences.php +msgid "Internal Networks" +msgstr "" + +#: nbar_preferences.php +msgid "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." +msgstr "" + +#: nbar_preferences.php +msgid " — combining off" +msgstr "" + +#: nbar_preferences.php +msgid " — select another capture or untick all to save" +msgstr "" + +#: nbar_preferences.php +msgid "Average rate across the selected timespan." +msgstr "" + +#: nbar_preferences.php +msgid "Decimal units: 1 GB = 1,000 MB." +msgstr "" + +#: nbar_preferences.php +msgid "{count} of 3 selected" +msgstr "" + +#: nbar_report.php +msgid "NBAR history is being upgraded. Refresh this report shortly." +msgstr "" + +#: nbar_report.php +#, php-format +msgid "That period is outside the %s-day NBAR retention setting. Showing the last half hour." +msgstr "" + +#: nbar_report.php +#, php-format +msgid "Dates adjusted to the %s-day NBAR retention window." +msgstr "" + +#: nbar_report.php +msgid "Application Report: Cisco Apps Recognition (NBAR)" +msgstr "" + +#: nbar_report.php +msgid "Router / capture" +msgstr "" + +#: nbar_report.php +msgid "Combined routers" +msgstr "" + +#: nbar_report.php +msgid "Setup guide" +msgstr "" + +#: nbar_report.php +msgid "IP version" +msgstr "" + +#: nbar_report.php +msgid "IPv4 and IPv6" +msgstr "" + +#: nbar_report.php +msgid "Connections" +msgstr "" + +#: nbar_report.php +msgid "Conversations" +msgstr "" + +#: nbar_report.php +msgid "Top applications" +msgstr "" + +#: nbar_report.php +msgid "Any IPv4 or IPv6 address" +msgstr "" + +#: nbar_report.php +msgid "Filter by IP" +msgstr "" + +#: nbar_report.php +msgid "Show traffic where this IP is the source or destination. Leave blank to show all." +msgstr "" + +#: nbar_report.php +msgid "← Back to conversations" +msgstr "" + +#: nbar_report.php +msgid "Unable to load connections. Try a shorter timespan or filter by IP." +msgstr "" + +#: nbar_report.php +msgid "Counting total pages…" +msgstr "" + +#: nbar_report.php +msgid "Page" +msgstr "" + +#: nbar_report.php +msgid "Up to 100 connections per page, ordered by data volume." +msgstr "" + +#: nbar_report.php +msgid "Previous" +msgstr "" + +#: nbar_report.php +msgid "Next" +msgstr "" + +#: nbar_report.php +#, php-format +msgid "Application %s" +msgstr "" + +#: nbar_report.php +msgid "Destination DNS" +msgstr "" + +#: nbar_report.php +msgid "Destination port" +msgstr "" + +#: nbar_report.php +msgid "Reverse DNS hostname" +msgstr "" + +#: nbar_report.php +msgid "Source port" +msgstr "" + +#: nbar_report.php +msgid "Bandwidth (Avg Mbps)" +msgstr "" + +#: nbar_report.php +msgid "Data (GB)" +msgstr "" + +#: nbar_report.php +msgid "First FlowView observation in the selected period" +msgstr "" + +#: nbar_report.php +msgid "Last FlowView observation in the selected period" +msgstr "" + +#: nbar_report.php +msgid "Records" +msgstr "" + +#: nbar_report.php +msgid "No application records in this view yet. Try a longer period or another traffic view." +msgstr "" + +#: nbar_report.php +msgid "Total pages unavailable" +msgstr "" + +#: nbar_report.php +msgid "of {pages} pages ({connections} connections)" +msgstr "" + +#: nbar_report.php +msgid "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." +msgstr "" + +#: nbar_seen.php +msgid "Unavailable" +msgstr "" + +#: nbar_seen.php +#, php-format +msgid "FlowView observation within the selected period; minute precision. Time zone: %s" +msgstr "" + +#: nbar_setup_guide.php +msgid "1. FlowView listener" +msgstr "" + +#: nbar_setup_guide.php +msgid "2. Router configuration example" +msgstr "" + +#: nbar_setup_guide.php +msgid "Create and enable a listener with the correct UDP port and allowed router export-source address. Start or restart the capture service after adding a listener. Allow the UDP port through the collector firewall. The export source must reach the collector. An IPv4 exporter can carry both IPv4 and IPv6 records." +msgstr "" + +#: nbar_setup_guide.php +msgid "Replace the red placeholders with your own values:" +msgstr "" + +#: nbar_setup_guide.php +msgid "Requirements" +msgstr "" + +#: nbar_setup_guide.php +msgid "Setup guide — Cisco Apps (NBAR)" +msgstr "" + +#: nbar_setup_guide.php +msgid "This configuration example is based on a Cisco 1100 Series ISR installation using NBAR2, Flexible NetFlow and application export. For other router types, refer to Cisco documentation for your exact router model and IOS XE release for supported commands, features and configuration requirements. Input/output monitoring and licence requirements vary by platform. Protocol packs must match their supported IOS XE release and NBAR engine. This guide does not apply router changes." +msgstr "" + +#: nbar_setup_guide.php +msgid "is a router interface whose address can reach the collector;" +msgstr "" + +#: nbar_setup_guide.php +msgid "is the Cacti collector address;" +msgstr "" + +#: nbar_setup_guide.php +msgid "is the listener UDP port;" +msgstr "" + +#: nbar_setup_guide.php +msgid "is the routed LAN interface to monitor. The export source identifies the router sending flow records; it does not choose which traffic is captured. It may differ from the monitored LAN VLAN. Set the FlowView listener to allow the chosen export-source IP. This example uses NetFlow v9 application records from a Cisco 1100 Series ISR. It omits optional routing metadata. Check Cisco documentation for defaults and supported record fields on your platform. The NBAR decoder currently supports application records exported using NetFlow v9; IPFIX application records are not decoded by this integration." +msgstr "" + +#: nbar_setup_guide.php +msgid "3. Capture and report settings" +msgstr "" + +#: nbar_setup_guide.php +msgid "4. Verify collection" +msgstr "" + +#: nbar_setup_guide.php +msgid "For example, replace" +msgstr "" + +#: nbar_setup_guide.php +msgid "Top applications and Connections use exported addresses directly. Conversations needs internal IPv4 and IPv6 networks in Options to distinguish Internal Hosts from External Hosts. Update delegated IPv6 prefixes when they change; retain old prefixes while viewing their history." +msgstr "" + +#: nbar_setup_guide.php +msgid "Use your monitor and exporter names. Generate traffic and check both caches and export counters. Allow 1-2 minutes for exports to appear. Check QFP load during a speed test; overhead depends on platform, traffic and capture count." +msgstr "" + +#: nbar_setup_guide.php +msgid "if that is the routed gateway interface for your LAN. Apply both the IPv4 and IPv6 monitors in input and output directions on that interface, as shown above. Input captures traffic arriving from that LAN towards the WAN; output captures traffic returning from the WAN to that LAN. This covers LAN-to-WAN traffic routed through that interface without also attaching the same monitors to the WAN. Use your actual routed LAN interface or VLAN interface; Vlan1 is an example, not a requirement. Traffic routed between VLANs can also cross this interface, so the capture is not limited to Internet traffic. Locally switched traffic that does not cross the router is outside this capture. Avoid combining LAN and WAN copies of the same traffic. For combined routers, select up to three captures, normally one LAN capture per router." +msgstr "" + +#: nbar_setup_guide.php +msgid "with" +msgstr "" + +#: nbar_setup_guide.php +msgid "Average bandwidth covers the selected timespan, not an instantaneous or peak rate. Conversations combines both directions for each Internal Host, External Host and application. Classification comes from the router; encrypted traffic can remain SSL or QUIC. DNS names identify hosts, not the exact HTTPS page." +msgstr "" + +#: nbar_setup_guide.php +msgid "Reading reports" +msgstr "" + +#: nbar_timespan.php +#, php-format +msgid "Last %s Days" +msgstr "" + +#: nbar_timespan.php +msgid "Last Day" +msgstr "" + #: setup.php msgid "NetFlow User" msgstr "" @@ -2647,6 +3103,34 @@ msgstr "" msgid "%d Year" msgstr "" +#: setup.php +msgid "Cisco Apps (NBAR) History Retention" +msgstr "" + +#: setup.php +msgid "Days of application history to retain. Reducing this removes older history during scheduled cleanup; increasing it cannot restore expired data." +msgstr "" + +#: setup.php +msgid "1 Day" +msgstr "" + +#: setup.php +msgid "14 Days" +msgstr "" + +#: setup.php +msgid "3 Days" +msgstr "" + +#: setup.php +msgid "30 Days" +msgstr "" + +#: setup.php +msgid "7 Days" +msgstr "" + #: setup.php msgid "Database Partitioning Scheme" msgstr "" diff --git a/manifest.json b/manifest.json index 00e76681..66988a20 100644 --- a/manifest.json +++ b/manifest.json @@ -27,6 +27,7 @@ "flowview_process.php", "flowview_runner.php", "flowview_schedules.php", + "flowview_sections.php", "flowview_upgrade.php", "flowview_upgrade_nat_columns.php", "images/", @@ -34,9 +35,26 @@ "includes/", "index.php", "irr_tables.php", + "js/", "locales/", "manifest.json", "maxscale/", + "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", "plugin_flowview_ports.sql", "repair_tables.php", "run_schedule.php", diff --git a/nbar_chart.php b/nbar_chart.php new file mode 100644 index 00000000..4bc3bb77 --- /dev/null +++ b/nbar_chart.php @@ -0,0 +1,79 @@ +=? AND d.minute 0) { + $items[] = ['name' => __('Other', 'flowview'), 'bytes' => $other]; + } + $colors = ['#4c9bd6', '#73ae53', '#e2a545', '#a780c5', '#de7970', '#91979d']; + $stops = []; + $legend = ''; + $position = 0; + foreach ($items as $i => $item) { + $bytes = (float) $item['bytes']; + if ($bytes <= 0) { + continue; + } + $next = min(100, $position + $bytes / $total * 100); + $stops[] = $colors[$i] . ' ' . round($position, 5) . '% ' . round($next, 5) . '%'; + $position = $next; + $value = $bytes >= 1000000000 ? number_format($bytes / 1000000000, 2) . ' GB' : number_format($bytes / 1000000, 2) . ' MB'; + $legend .= '
  • ' . fv_nbar_h($item['name']) . '' . $value . '
  • '; + } + echo ''; +} diff --git a/nbar_collector.php b/nbar_collector.php new file mode 100644 index 00000000..fcfd25a9 --- /dev/null +++ b/nbar_collector.php @@ -0,0 +1,166 @@ += 0; $i--) { + $carry += ((int) $digits[$i]) * 256; + $out = ($carry % 10) . $out; + $carry = intdiv($carry, 10); + } + $n = ($carry > 0 ? (string) $carry : '') . $out; + } + return $n; +} +/** + * Decode template-defined NetFlow v9 application records and names. + * + * @param string $p NetFlow v9 packet bytes. + * @param string $router Exporter source IP address. + * + * @return void + */ +function fv_nbar_packet($p, $router) { + global $listener_id; + static $templates = []; + if (strlen($p) < 20 || fv_nbar_uint(substr($p, 0, 2)) !== 9) { + return; + } + $key = $router . ':' . bin2hex(substr($p, 16, 4)); + $unix = fv_nbar_uint(substr($p, 8, 4)); + $uptime = fv_nbar_uint(substr($p, 4, 4)); + $rows = []; + $args = []; + $hours = []; + for ($o = 20;$o + 4 <= strlen($p);) { + $id = fv_nbar_uint(substr($p, $o, 2)); + $len = fv_nbar_uint(substr($p, $o + 2, 2)); + if ($len < 4 || $o + $len > strlen($p)) { + break; + } + $end = $o + $len; + $q = $o + 4; + if ($id === 0 || $id === 1) { + while ($q + ($id === 0 ? 4 : 6) <= $end) { + $tid = fv_nbar_uint(substr($p, $q, 2)); + if ($tid < 256) { + break; + } + if ($id === 0) { + $count = fv_nbar_uint(substr($p, $q + 2, 2)); + $size = $count * 4; + $q += 4; + } else { + $size = fv_nbar_uint(substr($p, $q + 2, 2)) + fv_nbar_uint(substr($p, $q + 4, 2)); + $q += 6; + } + if ($size < 4 || $size % 4 || $q + $size > $end) { + break; + } + $fields = []; + $total = 0; + for ($z = 0;$z < $size;$z += 4) { + $f = fv_nbar_uint(substr($p, $q + $z, 2)); + $l = fv_nbar_uint(substr($p, $q + $z + 2, 2)); + $fields[] = [$f, $l]; + $total += $l; + } + if ($total > 0 && $total <= 65535) { + $templates[$key][$tid] = [$fields, $total, $id === 1]; + } + $q += $size; + } + } elseif ($id >= 256 && isset($templates[$key][$id])) { + list($fields, $total, $options) = $templates[$key][$id]; + while ($q + $total <= $end) { + $data = []; + foreach ($fields as $f) { + $data[$f[0]] = substr($p, $q, $f[1]); + $q += $f[1]; + } + if (!isset($data[95])) { + continue; + } + $aid = bin2hex($data[95]); + if ($options && isset($data[96])) { + $name = trim($data[96], chr(0) . ' '); + if ($name !== '' && strlen($name) <= 255 && preg_match('/^[a-zA-Z0-9_.: -]+$/D', $name)) { + flowview_db_execute_prepared('INSERT INTO plugin_flowview_nbar_names VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE name=VALUES(name),updated=VALUES(updated)', [$router, $aid, $name, time()]); + } + continue; + } + $src = $data[27] ?? $data[8] ?? ''; + $dst = $data[28] ?? $data[12] ?? ''; + if (!in_array(strlen($src), [4, 16], true) || !in_array(strlen($dst), [4, 16], true) || !isset($data[1])) { + continue; + } + $ts = $unix; + if (isset($data[21])) { + $age = ($uptime - fv_nbar_uint($data[21]) + 4294967296) % 4294967296; + $ts = $unix - (int) floor($age / 1000); + } + if ($ts < time() - fv_nbar_retention_days() * 86400 || $ts > time() + 300) { + continue; + } + $hours[(int) (floor($ts / 3600) * 3600)] = true; + $rows[] = '(?,?,?,UNHEX(?),UNHEX(?),?,?,?,?,?,1)'; + array_push( + $args, + (int) $listener_id, + (int) (floor($ts / 60) * 60), + $aid, + bin2hex($src), + bin2hex($dst), + fv_nbar_uint($data[7] ?? chr(0)), + fv_nbar_uint($data[11] ?? chr(0)), + fv_nbar_uint($data[4] ?? chr(0)), + fv_nbar_uint($data[1]), + fv_nbar_uint($data[2] ?? chr(0)) + ); + } + } + $o = $end; + } + if ($rows && !fv_nbar_summary_store('INSERT INTO plugin_flowview_nbar_data VALUES ' . implode(',', $rows) . ' ON DUPLICATE KEY UPDATE bytes=bytes+VALUES(bytes),packets=packets+VALUES(packets),flows=flows+1', $listener_id, $hours, $args)) { + cacti_log('WARNING: NBAR record transaction failed', false, 'FLOWVIEW'); + } +} diff --git a/nbar_common.php b/nbar_common.php new file mode 100644 index 00000000..c6e03b89 --- /dev/null +++ b/nbar_common.php @@ -0,0 +1,79 @@ += 60) { + $value = (int) read_config_option('flowview_nbar_retention_days', true); + $days = in_array($value, [1, 3, 7, 14, 30], true) ? $value : 7; + $checked = time(); + } + return $days; +} +/** + * Expire raw records and summaries in bounded database batches. + * + * @return void + */ +function fv_nbar_cleanup() { + fv_nbar_daily_cleanup(); + $cutoff = (int) (floor((time() - fv_nbar_retention_days() * 86400) / 3600) * 3600); + foreach (['apps', 'pairs', 'connections', 'hours'] as $table) { + flowview_db_execute("DELETE FROM plugin_flowview_nbar_$table WHERE hour<$cutoff LIMIT 50000"); + } + flowview_db_execute('DELETE FROM plugin_flowview_nbar_data WHERE minute < ' . (time() - fv_nbar_retention_days() * 86400) . ' LIMIT 50000'); +} diff --git a/nbar_connections.php b/nbar_connections.php new file mode 100644 index 00000000..121e9d9f --- /dev/null +++ b/nbar_connections.php @@ -0,0 +1,365 @@ + strlen($b) ?: strcmp($a, $b); +} +/** + * Identify an exported tuple using the resolved application-name rank. + * + * @param array $row Exported tuple or observation fields. + * @param array $names Application-name ranks by capture. + * @param int $listener Capture identifier. + * + * @return string + */ +function fv_nbar_connection_key($row, $names, $listener) { + return json_encode([(string) $row['app_id'], (int) ($names[$listener][$row['app_id']]['name_id'] ?? 0), $row['src_hex'], $row['dst_hex'], (int) $row['sport'], (int) $row['dport'], (int) $row['protocol']]); +} +/** + * Order connection totals by exact byte count and deterministic tuple fields. + * + * @param array $a First connection total with its normalized tuple key. + * @param array $b Second connection total with its normalized tuple key. + * + * @return int + */ +function fv_nbar_connection_compare($a, $b) { + $cmp = fv_nbar_decimal_compare($b['bytes'], $a['bytes']); + if ($cmp) { + return $cmp; + } + foreach ($a['_key'] as $i => $v) { + $cmp = ($i === 0 || $i === 2 || $i === 3) ? strcmp((string) $v, (string) $b['_key'][$i]) : ($v <=> $b['_key'][$i]); + if ($cmp) { + return $cmp; + } + } + return 0; +} +// Read completed hours independently instead of regrouping a whole partial day. +/** + * Plan independently reusable complete hours and raw partial boundaries. + * + * @param int $listener Capture identifier. + * @param int $from Inclusive boundary in Unix seconds. + * @param int $to Exclusive boundary in Unix seconds. + * @param int $now Current Unix time, or null to read the clock. + * + * @return array + */ +function fv_nbar_connection_edges($listener, $from, $to, $now) { + $segments = []; + $ready = flowview_db_fetch_assoc_prepared('SELECT hour FROM plugin_flowview_nbar_hours WHERE listener_id=? AND hour>=? AND hour=0 AND connection_built=revision', [$listener, (int) (ceil($from / 3600) * 3600), (int) (floor(min($to, $now - 300) / 3600) * 3600)]); + if (!is_array($ready)) { + throw new RuntimeException('Cannot read hourly summary state'); + } + $hours = array_fill_keys(array_column($ready, 'hour'), true); + for ($cursor = $from;$cursor < $to;$cursor = $end) { + $hour = (int) (floor($cursor / 3600) * 3600); + $end = min($to, $hour + 3600); + if ($cursor == $hour && $end == $hour + 3600 && isset($hours[$hour])) { + $source = '(SELECT listener_id,hour minute,app_id,src,dst,sport,dport,protocol,bytes,packets,flows,first_seen,last_seen FROM plugin_flowview_nbar_connections WHERE listener_id=' . (int) $listener . ' AND hour=' . $hour . ')'; + $segments[] = ['listener' => $listener, 'source' => $source, 'daily' => true]; + } else { + $source = '(SELECT listener_id,minute,app_id,src,dst,sport,dport,protocol,bytes,packets,flows,minute first_seen,minute last_seen FROM plugin_flowview_nbar_data WHERE listener_id=' . (int) $listener . ' AND minute>=' . (int) $cursor . ' AND minute<' . (int) $end . ')'; + $segments[] = ['listener' => $listener, 'source' => $source, 'daily' => false]; + } + } + return $segments; +} +/** + * Plan a capture range using current daily, hourly and raw revisions. + * + * @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 $now Current Unix time, or null to read the clock. + * + * @return array + */ +function fv_nbar_connection_plan($listeners, $start, $end, $now) { + $segments = []; + foreach ($listeners as $listener) { + $listener = (int) $listener; + if ($listener <= 0) { + throw new InvalidArgumentException('Invalid capture'); + } + $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]); + if (!is_array($days)) { + throw new RuntimeException('Cannot read daily history'); + } + $cursor = $start; + foreach ($days as $day) { + $from = (int) $day['day']; + $to = (int) $day['covered_to']; + if ($day['timezone'] !== date_default_timezone_get() || $from < $cursor || $to > (int) (floor(($now - 300) / 3600) * 3600)) { + continue; + } + if (fv_nbar_daily_signature($listener, $from, $to) !== (string) $day['revision']) { + continue; + } + if ($from > $cursor) { + $segments = array_merge($segments, fv_nbar_connection_edges($listener, $cursor, $from, $now)); + } + $segments[] = ['listener' => $listener, 'source' => '(SELECT listener_id,hour minute,app_id,src,dst,sport,dport,protocol,bytes,packets,flows,first_seen,last_seen FROM plugin_flowview_nbar_daily_connections WHERE listener_id=' . $listener . ' AND hour=' . $from . ')', 'daily' => true]; + $cursor = $to; + } + if ($cursor < $end) { + $segments = array_merge($segments, fv_nbar_connection_edges($listener, $cursor, $end, $now)); + } + } + return $segments; +} +/** + * Read an exact connection page from a consistent history snapshot. + * + * @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 string $where Validated filter predicate. + * @param array $args Prepared statement values. + * @param int $page One-based connection page. + * @param int $size Bounded page size including the next-page sentinel. + * + * @return array + */ +function fv_nbar_connections_direct($listeners, $start, $end, $where, $args, $page, $size = 101) { + $listeners = array_values(array_unique(array_map('intval', $listeners))); + if (!$listeners || cacti_sizeof($listeners) > 3) { + throw new InvalidArgumentException('Choose one to three captures'); + } + $page = max(1, (int) $page); + $size = max(101, min(501, (int) $size)); + $offset = ($page - 1) * 100; + $need = $offset + $size; + // Keep page retrieval bounded. Deep jumps use the exact SQL fallback below. + $limit = max(256, min(4096, $need * 2)); + if (!flowview_db_execute('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ') || !flowview_db_execute('START TRANSACTION WITH CONSISTENT SNAPSHOT')) { + throw new RuntimeException('Cannot open history snapshot'); + } + try { + $segments = fv_nbar_connection_plan($listeners, (int) $start, (int) $end, time()); + $nameRows = flowview_db_fetch_assoc_prepared('SELECT l.id listener_id,n.app_id,n.name,DENSE_RANK() OVER (ORDER BY n.name) name_id FROM plugin_flowview_devices l LEFT JOIN plugin_flowview_nbar_names n ON n.router=l.allowfrom WHERE l.id IN (' . implode(',', $listeners) . ')', []); + if (!is_array($nameRows)) { + throw new RuntimeException('Cannot read application names'); + } + $names = []; + foreach ($nameRows as $row) { + if ($row['app_id'] !== null) { + $names[(int) $row['listener_id']][$row['app_id']] = $row; + } + } + $fields = 'd.app_id,HEX(d.src) src_hex,HEX(d.dst) dst_hex,d.sport,d.dport,d.protocol'; + $group = 'd.app_id,d.src,d.dst,d.sport,d.dport,d.protocol'; + $counters = 'd.bytes bytes,d.packets packets,d.flows records,d.first_seen,d.last_seen'; + $sum = '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'; + $order = 'bytes DESC'; + for ($round = 0;$round < 7 && $need <= 32768;$round++,$limit *= 2) { + $candidates = []; + $bound = '0'; + $exhausted = true; + foreach ($segments as $segment) { + $sql = 'SELECT ' . $fields . ',' . ($segment['daily'] ? $counters : $sum) . ' FROM ' . $segment['source'] . ' d WHERE ' . $where . ($segment['daily'] ? '' : ' GROUP BY ' . $group) . ' ORDER BY ' . $order . ' LIMIT ' . ($limit + 1); + $rows = flowview_db_fetch_assoc_prepared($sql, $args); + if (!is_array($rows)) { + throw new RuntimeException('Cannot read connection page'); + } + if (cacti_sizeof($rows) > $limit) { + $exhausted = false; + $last = array_pop($rows); + $bound = fv_nbar_decimal_add($bound, $last['bytes']); + } + foreach ($rows as $row) { + $candidates[fv_nbar_connection_key($row, $names, $segment['listener'])] = $row; + } + } + if (!$candidates) { + flowview_db_execute('COMMIT'); + return []; + } + // Sparse histories produce different candidates for each day. Bound + // cross-day probes before they cost more than one grouped SQL query. + if (cacti_sizeof($candidates) >= 1024) { + break; + } + // Look up every candidate in every day, including small contributions + // outside each day's top list. The final totals are exact, not sampled. + $totals = []; + foreach ($candidates as $key => $row) { + $tuple = json_decode($key, true); + $row['name'] = null; + foreach ($nameRows as $name) { + if ((int) $name['name_id'] === $tuple[1]) { + $row['name'] = $name['name']; + break; + } + } + $row['first_seen'] = null; + $row['last_seen'] = null; + $row['_seen_missing'] = false; + $row['bytes'] = '0'; + $row['packets'] = '0'; + $row['records'] = '0'; + $totals[$key] = $row; + } + $tuples = []; + foreach ($candidates as $row) { + $tuples[json_encode([$row['app_id'], $row['src_hex'], $row['dst_hex'], $row['sport'], $row['dport'], $row['protocol']])] = $row; + } + foreach ($segments as $segment) { + foreach (array_chunk(array_values($tuples), 400) as $chunk) { + $lookup = []; + $values = []; + foreach ($chunk as $row) { + $lookup[] = '(?,UNHEX(?),UNHEX(?),?,?,?)'; + array_push($values, $row['app_id'], $row['src_hex'], $row['dst_hex'], $row['sport'], $row['dport'], $row['protocol']); + } + $sql = 'SELECT ' . $fields . ',' . ($segment['daily'] ? $counters : $sum) . ' FROM ' . $segment['source'] . ' d WHERE ' . $where . ' AND (' . $group . ') IN (' . implode(',', $lookup) . ')' . ($segment['daily'] ? '' : ' GROUP BY ' . $group); + $rows = flowview_db_fetch_assoc_prepared($sql, array_merge($args, $values)); + if (!is_array($rows)) { + throw new RuntimeException('Cannot total connection page'); + } + foreach ($rows as $row) { + $key = fv_nbar_connection_key($row, $names, $segment['listener']); + if (!isset($totals[$key])) { + continue; + } + fv_nbar_seen_merge($totals[$key], $row); + foreach (['bytes', 'packets', 'records'] as $counter) { + $totals[$key][$counter] = fv_nbar_decimal_add($totals[$key][$counter], $row[$counter]); + } + } + } + } + $ranked = []; + foreach ($totals as $key => $row) { + if ($row['_seen_missing']) { + $row['first_seen'] = null; + $row['last_seen'] = null; + } unset($row['_seen_missing']); + $row['_key'] = json_decode($key, true); + $ranked[] = $row; + } + usort($ranked, 'fv_nbar_connection_compare'); + // Any unseen connection has at most $bound bytes across all segments. + // Strict inequality also protects deterministic ties at page boundaries. + if ($exhausted || (cacti_sizeof($ranked) >= $need && fv_nbar_decimal_compare($ranked[$need - 1]['bytes'], $bound) > 0)) { + $result = array_slice($ranked, $offset, $size); + foreach ($result as &$row) { + $row['src'] = inet_ntop(hex2bin($row['src_hex'])); + $row['dst'] = inet_ntop(hex2bin($row['dst_hex'])); + unset($row['_key'],$row['src_hex'],$row['dst_hex']); + } unset($row); + flowview_db_execute('COMMIT'); + return $result; + } + } + // Exact fallback for exceptionally deep pages or a flat traffic distribution. + $source = '(' . implode(' UNION ALL ', array_map(static function ($segment) {return 'SELECT * FROM ' . $segment['source'] . ' s';}, $segments)) . ')'; + $result = flowview_db_fetch_assoc_prepared(str_replace(' LIMIT 101 OFFSET ', ' LIMIT ' . $size . ' OFFSET ', fv_nbar_connections_sql($source, $where, $page)), $args); + if (!is_array($result)) { + throw new RuntimeException('Cannot read connection page'); + } + flowview_db_execute('COMMIT'); + return $result; + } catch (Throwable $e) { + flowview_db_execute('ROLLBACK'); + throw $e; + } +} + +// Cache requested pages, never an entire report. Authorization is in the key. +/** + * Cache only requested connection pages within the current account. + * + * @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 string $where Validated filter predicate. + * @param array $args Prepared statement values. + * @param int $page One-based connection page. + * + * @return array + */ +function fv_nbar_connections_cached($listeners, $start, $end, $where, $args, $page) { + if (session_status() !== PHP_SESSION_ACTIVE) { + return fv_nbar_connections_direct($listeners, $start, $end, $where, $args, $page); + } + $block = max(1, (int) $page); + $slice = 0; + $key = hash('sha256', serialize(['requested-page-seen-v3', $_SESSION['sess_user_id'] ?? 0, $listeners, $start, $end, $where, $args, $block])); + $cache = $_SESSION['fv_nbar_connection_pages'] ?? []; + foreach ($cache as $id => $entry) { + if ($entry['until'] <= time()) { + unset($cache[$id]); + } + } + if (isset($cache[$key])) { + $_SESSION['fv_nbar_connection_pages'] = $cache; + return array_slice($cache[$key]['rows'], $slice, 101); + } + $rows = fv_nbar_connections_direct($listeners, $start, $end, $where, $args, $block, 101); + if (strlen(serialize($rows)) <= 524288) { + while (cacti_sizeof($cache) >= 3) { + array_shift($cache); + } $cache[$key] = ['until' => time() + ($end < time() - 300 ? 120 : 30), 'rows' => $rows]; + } + $_SESSION['fv_nbar_connection_pages'] = $cache; + return array_slice($rows, $slice, 101); +} diff --git a/nbar_conversations.php b/nbar_conversations.php new file mode 100644 index 00000000..eda3e775 --- /dev/null +++ b/nbar_conversations.php @@ -0,0 +1,88 @@ +' . html_escape(__('Configure your site networks in Application settings to label internal and external devices. Top applications and Connections work directly with exported IPv4 and IPv6 addresses.', 'flowview')) . '

    '; + return; +} +$where = $listener_scope . ' AND d.minute>=? AND d.minute' . html_escape(__('← Back to top applications', 'flowview')) . '

    '; +} +echo '

    ' . html_escape(__('Internal Host ↔ External Host. Click a device to see its individual connections. Bandwidth is averaged over the selected timespan.', 'flowview')) . '

    '; +echo '
    '; +foreach ([__('Internal Host', 'flowview'), __('External Host', 'flowview'), __('Application', 'flowview'), __('Download (GB)', 'flowview'), __('Upload (GB)', 'flowview'), __('Download (Avg Mbps)', 'flowview'), __('Upload (Avg Mbps)', 'flowview'), __('First seen', 'flowview'), __('Last seen', 'flowview')] as $index => $h) { + $unit = [3 => 'data', 4 => 'data', 5 => 'rate', 6 => 'rate'][$index] ?? ''; + echo ''; +} +echo ''; +$i = 0; +foreach ($rows as $r) { + $link = $base . '&parent_app=' . rawurlencode($aid) . '&mode=connections&app=' . $r['app_id'] . '&internal=' . rawurlencode($r['internal']) . '&external=' . rawurlencode($r['external']); + echo ''; + foreach (['internal', 'external'] as $f) { + $dns = fv_nbar_dns($r[$f]); + echo ''; + } + echo ''; + foreach ([$r['download'] / 1e9, $r['upload'] / 1e9, $r['download'] * 8 / $seconds / 1e6, $r['upload'] * 8 / $seconds / 1e6] as $j => $v) { + $precision = $j < 2 ? 4 : 3; + $threshold = pow(10, -$precision); + echo ''; + } + echo fv_nbar_seen_cells($r) . ''; +} +if (!$rows) { + echo ''; +} +echo '
    ' . fv_nbar_h($h) . '
    ' . fv_nbar_h($r[$f]) . '
    ' . fv_nbar_h($dns) . '
    ' . fv_nbar_h($r['name'] ?? __('Awaiting name (%s)', $r['app_id'], 'flowview')) . '' . ($v > 0 && $v < $threshold ? '<' . number_format($threshold, $precision) : number_format($v, $precision)) . '
    ' . html_escape(__('No conversations for this period. Allow 1–2 minutes for exports, or choose a longer timespan.', 'flowview')) . '
    ' . 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')) . '

    Top 100 conversations by combined data volume. Each row groups an Internal Host, External Host and application across both directions. Uses only the selected LAN captures to avoid counting WAN copies. Local and multicast traffic is excluded. DNS names identify servers, not the exact website requested. History retention: ' . fv_nbar_retention_days() . ' days.

    '; + +echo ''; diff --git a/nbar_count.php b/nbar_count.php new file mode 100644 index 00000000..a4f2fce4 --- /dev/null +++ b/nbar_count.php @@ -0,0 +1,159 @@ + $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 = flowview_db_fetch_row_prepared('SET STATEMENT max_statement_time=90 FOR ' . $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]); +} diff --git a/nbar_daily.php b/nbar_daily.php new file mode 100644 index 00000000..630ff863 --- /dev/null +++ b/nbar_daily.php @@ -0,0 +1,287 @@ +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 = (float) flowview_db_fetch_cell('SELECT @@session.max_statement_time'); + flowview_db_execute('SET SESSION max_statement_time=20'); + 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) + FROM $source d WHERE d.listener_id=$listener AND d.minute>=$from AND d.minute<$to GROUP BY $fields + 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() { + $cutoff = time() - fv_nbar_retention_days() * 86400; + foreach (['apps', 'pairs', 'connections'] as $kind) { + flowview_db_execute("DELETE FROM plugin_flowview_nbar_daily_$kind WHERE hour<$cutoff LIMIT 5000"); + } + flowview_db_execute("DELETE FROM plugin_flowview_nbar_days WHERE day<$cutoff LIMIT 100"); +} +/** + * 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 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'; + } 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'; + } else { + $fields = 'listener_id,hour minute,app_id,src,dst,sport,dport,protocol,bytes,packets,flows,first_seen,last_seen'; + } + $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 00000000..5f359e0b --- /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 00000000..5e0e1d1a --- /dev/null +++ b/nbar_feed_status.php @@ -0,0 +1,58 @@ +' . 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]); + $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 00000000..4f96a398 --- /dev/null +++ b/nbar_preferences.php @@ -0,0 +1,178 @@ + 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 ''; + +} diff --git a/nbar_query.php b/nbar_query.php new file mode 100644 index 00000000..57dbdf12 --- /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,n.name_id,d.src,d.dst,d.sport,d.dport,d.protocol'; + $inner = 'SELECT d.app_id,n.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=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.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,n.name,d.src,d.dst,d.sport,d.dport,d.protocol'; +} diff --git a/nbar_report.php b/nbar_report.php new file mode 100644 index 00000000..9962f81c --- /dev/null +++ b/nbar_report.php @@ -0,0 +1,373 @@ +' . 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,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 JOIN plugin_flowview_devices l ON l.id=d.listener_id LEFT JOIN plugin_flowview_nbar_names n ON n.router=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); +} +$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 00000000..48c17d75 --- /dev/null +++ b/nbar_seen.php @@ -0,0 +1,209 @@ + 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; + } + 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 = flowview_db_fetch_cell('SELECT @@session.max_statement_time'); + $isolation = flowview_db_fetch_cell('SELECT @@session.tx_isolation'); + 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; + } + flowview_db_execute('SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED'); + flowview_db_execute('SET SESSION max_statement_time=20'); + $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; + $keys = $kind === 'apps' ? ['app_id', 'family'] : explode(',', $fields); + $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,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 { + flowview_db_execute('SET SESSION max_statement_time=' . (float) $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 00000000..6ad50e13 --- /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 00000000..86158efe --- /dev/null +++ b/nbar_summary.php @@ -0,0 +1,229 @@ + '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) { + $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'])) { + $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) { + // 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; + } + try { + $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)$where GROUP BY $fields"; + 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; + } +} + +/** + * 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 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 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'; + 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"; + } 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'; + } else { + $fields = 'listener_id,hour minute,app_id,src,dst,sport,dport,protocol,bytes,packets,flows,first_seen,last_seen'; + } + $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 00000000..b789952f --- /dev/null +++ b/nbar_timespan.php @@ -0,0 +1,76 @@ + $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]; +} diff --git a/phpunit.xml b/phpunit.xml index 2ec8407a..03f0a9bb 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 8726c0f6..b66581b4 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') < 1) { $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,11 @@ 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') >= 1) { + fv_nbar_summary_refresh(); + fv_nbar_cleanup(); + } $retention_days = read_config_option('flowview_retention'); @@ -1069,6 +1097,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', '1'); 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 d536c9ae..d9a7b7c3 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 00000000..fdbee8fc --- /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 00000000..36c5b359 --- /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]); + } +}); + +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 00000000..63115f45 --- /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 00000000..d81f4d46 --- /dev/null +++ b/tests/Unit/NbarConnectionPagingTest.php @@ -0,0 +1,112 @@ +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']); diff --git a/tests/Unit/NbarCountRequestTest.php b/tests/Unit/NbarCountRequestTest.php new file mode 100644 index 00000000..5187127c --- /dev/null +++ b/tests/Unit/NbarCountRequestTest.php @@ -0,0 +1,65 @@ + ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + expect(is_resource($process))->toBeTrue(); + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + expect(proc_close($process))->toBe(0); + expect($stderr)->toBe(''); + $payload = json_decode($stdout, true, 512, JSON_THROW_ON_ERROR); + $result = json_decode(file_get_contents($directory . '/result.json'), true, 512, JSON_THROW_ON_ERROR); + expect($payload['status'])->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]); + } + // Child requests terminate with exit. Merge their real line hits so + // coverage includes endpoint paths under both PCOV and CI's Xdebug. + $coverage = \PHPUnit\Runner\CodeCoverage::instance(); + if ($result['coverage'] && $coverage->isActive()) { + $coverage->codeCoverage()->append(\SebastianBergmann\CodeCoverage\Data\RawCodeCoverageData::fromXdebugWithoutPathCoverage($result['coverage'])); + } + } 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 00000000..99126d97 --- /dev/null +++ b/tests/Unit/NbarDailyWorkerTest.php @@ -0,0 +1,127 @@ + 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'); +}); diff --git a/tests/Unit/NbarDirectRequestTest.php b/tests/Unit/NbarDirectRequestTest.php new file mode 100644 index 00000000..180e5ffe --- /dev/null +++ b/tests/Unit/NbarDirectRequestTest.php @@ -0,0 +1,46 @@ + ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + expect(is_resource($process))->toBeTrue(); + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + expect(proc_close($process))->toBe(0); + expect($stderr)->toBe(''); + $result = json_decode(file_get_contents($directory . '/result.json'), true, 512, JSON_THROW_ON_ERROR); + expect($stdout)->toBe(''); + expect($result['status'])->toBe($expected); + // Child requests terminate with exit. Merge their real line hits so + // coverage includes endpoint paths under both PCOV and CI's Xdebug. + $coverage = \PHPUnit\Runner\CodeCoverage::instance(); + if ($result['coverage'] && $coverage->isActive()) { + $coverage->codeCoverage()->append(\SebastianBergmann\CodeCoverage\Data\RawCodeCoverageData::fromXdebugWithoutPathCoverage($result['coverage'])); + } + } 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 00000000..e8fdac19 --- /dev/null +++ b/tests/Unit/NbarDnsTest.php @@ -0,0 +1,73 @@ + $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'); +}); + +it('stops further DNS lookups after the report lookup budget is exhausted', function () { + $GLOBALS['fv_nbar_preferences'] = ['dns' => '1']; + $queries = count(Net_DNS2_Resolver::$queries); + usleep(1100000); + expect(fv_nbar_dns('192.0.2.250'))->toBe('Not resolved'); + expect(Net_DNS2_Resolver::$queries)->toHaveCount($queries); +}); diff --git a/tests/Unit/NbarFailurePathsTest.php b/tests/Unit/NbarFailurePathsTest.php new file mode 100644 index 00000000..898ff82c --- /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, 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/NbarMaintenanceTest.php b/tests/Unit/NbarMaintenanceTest.php new file mode 100644 index 00000000..09148f2b --- /dev/null +++ b/tests/Unit/NbarMaintenanceTest.php @@ -0,0 +1,195 @@ + 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(end($sql))->toBe('COMMIT'); + $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(end($sql))->toBe('ROLLBACK'); + 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(end($sql))->toBe('ROLLBACK'); + 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(end($sql))->toBe('ROLLBACK'); +}); + +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', 7); + 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', 'max_statement_time=7', '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', 'count_part', 'listener_id=1'); + expect($sql)->not->toContain('SUM(d.bytes)'); +}); diff --git a/tests/Unit/NbarPacketTest.php b/tests/Unit/NbarPacketTest.php new file mode 100644 index 00000000..17125bf2 --- /dev/null +++ b/tests/Unit/NbarPacketTest.php @@ -0,0 +1,71 @@ +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']); diff --git a/tests/Unit/NbarRenderingTest.php b/tests/Unit/NbarRenderingTest.php new file mode 100644 index 00000000..d9cb7d35 --- /dev/null +++ b/tests/Unit/NbarRenderingTest.php @@ -0,0 +1,145 @@ + 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(''; } + +/** 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_timespan.php b/nbar_timespan.php index b789952f..85ebbc69 100644 --- a/nbar_timespan.php +++ b/nbar_timespan.php @@ -74,3 +74,19 @@ function fv_nbar_bound_dates($start, $end, $now, $days) { } 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/tests/Unit/NbarLatestReviewTest.php b/tests/Unit/NbarLatestReviewTest.php new file mode 100644 index 00000000..f23fed4d --- /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/bootstrap-unit.php b/tests/bootstrap-unit.php index dde42555..b78c4b2c 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -535,3 +535,9 @@ function exec_with_timeout($command, &$output, &$status, $timeout = 5) { return plugin_test_db_result('exec_with_timeout_result', null); } } + +if (!function_exists('db_affected_rows')) { + function db_affected_rows($conn = false) { + return plugin_test_db_result('db_affected_rows', 0); + } +} From fad9b037aa47a40b2b5a98a35702610699bcdec5 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:32:47 +1030 Subject: [PATCH 09/27] Reject invalid NBAR templates and preserve unnamed chart groups --- README.md | 2 ++ nbar_chart.php | 2 +- nbar_collector.php | 10 +++++++++- tests/Unit/NbarPacketTest.php | 31 +++++++++++++++++++++++++++++++ tests/Unit/NbarRenderingTest.php | 10 ++++++++++ 5 files changed, 53 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2ef38b0e..b71a4bc5 100644 --- a/README.md +++ b/README.md @@ -291,3 +291,5 @@ New NBAR records retain the actual exporter address through hourly and daily sum NBAR maintenance is launched independently of the poller. A registered supervisor runs the PHP maintenance child with a 90-second process deadline. MariaDB hourly mutations also set and restore a verified 20-second statement deadline. MySQL hourly builds remain available only inside the supervised child; raw records remain the fallback until a checkpoint is published. NBAR timespan requests keep their session state separate from Flow Reports. Conversation totals exclude IPv4 and IPv6 multicast endpoints, and feed lookup failures are shown separately from empty captures. Retention maintenance drains additional batches across raw, hourly and daily tables within a shared ten-second cleanup budget, under the supervised worker deadline. + +Template decoding rejects zero-length fields and invalidates the affected cached template until a valid refresh arrives. Chart fallback labels are grouped by their complete expression, so applications without learned names keep separate slices. diff --git a/nbar_chart.php b/nbar_chart.php index 747cc987..eb3e6c96 100644 --- a/nbar_chart.php +++ b/nbar_chart.php @@ -45,7 +45,7 @@ function fv_nbar_chart($scope, $args, $family, $host = '') { array_push($args, $host, $host); } $rows = fv_nbar_query_cached( - "SELECT COALESCE(NULLIF(n.name,''),CONCAT('Application ',d.app_id)) name,SUM(d.bytes) bytes FROM (SELECT d.listener_id,d.app_id,d.router,SUM(d.bytes) bytes FROM " . $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 name ORDER BY bytes DESC', + "SELECT COALESCE(NULLIF(n.name,''),CONCAT('Application ',d.app_id)) name,SUM(d.bytes) bytes FROM (SELECT d.listener_id,d.app_id,d.router,SUM(d.bytes) bytes FROM " . $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 COALESCE(NULLIF(n.name,\'\'),CONCAT(\'Application \',d.app_id)) ORDER BY bytes DESC', $args ); if (!is_array($rows)) { diff --git a/nbar_collector.php b/nbar_collector.php index 7a2251be..0af18b93 100644 --- a/nbar_collector.php +++ b/nbar_collector.php @@ -94,18 +94,26 @@ function fv_nbar_packet($p, $router) { $q += 6; } if ($size < 4 || $size % 4 || $q + $size > $end) { + unset($templates[$key][$tid]); break; } $fields = []; $total = 0; + $valid = true; for ($z = 0;$z < $size;$z += 4) { $f = fv_nbar_uint(substr($p, $q + $z, 2)); $l = fv_nbar_uint(substr($p, $q + $z + 2, 2)); + if ($l === 0) { + $valid = false; + break; + } $fields[] = [$f, $l]; $total += $l; } - if ($total > 0 && $total <= 65535) { + if ($valid && $total > 0 && $total <= 65535) { $templates[$key][$tid] = [$fields, $total, $id === 1]; + } else { + unset($templates[$key][$tid]); } $q += $size; } diff --git a/tests/Unit/NbarPacketTest.php b/tests/Unit/NbarPacketTest.php index 17125bf2..17c29eb6 100644 --- a/tests/Unit/NbarPacketTest.php +++ b/tests/Unit/NbarPacketTest.php @@ -69,3 +69,34 @@ 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([]); +}); diff --git a/tests/Unit/NbarRenderingTest.php b/tests/Unit/NbarRenderingTest.php index d9cb7d35..2a813ebc 100644 --- a/tests/Unit/NbarRenderingTest.php +++ b/tests/Unit/NbarRenderingTest.php @@ -143,3 +143,13 @@ include dirname(__DIR__, 2) . '/nbar_conversations.php'; expect(ob_get_clean())->toContain('No conversations for this period'); }); + +it('groups chart rows by the complete fallback label for missing names', function () { + ob_start(); + fv_nbar_chart('d.listener_id=?', [1, 1800000000, 1800001800], 'all'); + ob_end_clean(); + $queries = array_column($GLOBALS['__test_db_calls'], 'sql'); + $sql = implode(' ', $queries); + expect($sql)->toContain("GROUP BY COALESCE(NULLIF(n.name,''),CONCAT('Application ',d.app_id)) ORDER BY bytes DESC"); + expect($sql)->not->toContain('GROUP BY name ORDER BY'); +}); From 3c69e49195815d6cc6209a440f41c76dae2bba56 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:56:12 +1030 Subject: [PATCH 10/27] Bound NBAR template state and numeric decoding work --- README.md | 2 + nbar_collector.php | 101 ++++++++++++++++++++++++--- tests/Unit/NbarPacketTest.php | 52 +++++++++++++- tests/Unit/NbarTemplateCacheTest.php | 56 +++++++++++++++ 4 files changed, 200 insertions(+), 11 deletions(-) create mode 100644 tests/Unit/NbarTemplateCacheTest.php diff --git a/README.md b/README.md index b71a4bc5..99fa9e04 100644 --- a/README.md +++ b/README.md @@ -293,3 +293,5 @@ NBAR maintenance is launched independently of the poller. A registered superviso NBAR timespan requests keep their session state separate from Flow Reports. Conversation totals exclude IPv4 and IPv6 multicast endpoints, and feed lookup failures are shown separately from empty captures. Retention maintenance drains additional batches across raw, hourly and daily tables within a shared ten-second cleanup budget, under the supervised worker deadline. Template decoding rejects zero-length fields and invalidates the affected cached template until a valid refresh arrives. Chart fallback labels are grouped by their complete expression, so applications without learned names keep separate slices. + +The NBAR template cache retains at most 256 templates / 8,192 field definitions in total and 64 templates / 2,048 fields per exporter, across all source IDs. Least recently used entries are evicted and inactive templates expire after 30 minutes (removed lazily on packet activity). Evicted templates are learned again from the next exporter refresh. Numeric data fields must fit unsigned 64-bit values; options scope fields are decoded separately and are not treated as counters. diff --git a/nbar_collector.php b/nbar_collector.php index 0af18b93..04f468f3 100644 --- a/nbar_collector.php +++ b/nbar_collector.php @@ -25,6 +25,81 @@ require_once __DIR__ . "/nbar_common.php"; // Decode application-bearing NetFlow v9 records from exporter templates. +/** Bounded LRU template state, shared across source IDs but limited per exporter. */ +class FlowviewNbarTemplateCache +{ + private array $entries = []; + private int $pruned = 0; + + public function prune(int $now): void { + if ($now - $this->pruned < 60) { + return; + } + foreach ($this->entries as $key => $entry) { + if ($entry['used'] <= $now - 1800) { + unset($this->entries[$key]); + } + } + $this->pruned = $now; + } + + public function remove(string $router, string $source, int $id): void { + unset($this->entries[$router . ':' . $source . ':' . $id]); + } + + public function store(string $router, string $source, int $id, array $template, int $now): bool { + $this->prune($now); + $this->remove($router, $source, $id); + $fields = count($template[0]); + if ($fields > 2048) { + return false; + } + while (true) { + $usage = $this->usage($router); + $local = $usage['exporter_templates'] >= 64 || $usage['exporter_fields'] + $fields > 2048; + if (!$local && $usage['templates'] < 256 && $usage['fields'] + $fields <= 8192) { + break; + } + foreach ($this->entries as $key => $entry) { + if (!$local || $entry['router'] === $router) { + unset($this->entries[$key]); + break; + } + } + } + $this->entries[$router . ':' . $source . ':' . $id] = ['router' => $router, 'used' => $now, 'template' => $template]; + return true; + } + + public function get(string $router, string $source, int $id, int $now): ?array { + $key = $router . ':' . $source . ':' . $id; + if (!isset($this->entries[$key])) { + return null; + } + $entry = $this->entries[$key]; + unset($this->entries[$key]); + if ($entry['used'] <= $now - 1800) { + return null; + } + $entry['used'] = $now; + $this->entries[$key] = $entry; + return $entry['template']; + } + + public function usage(string $router): array { + $usage = ['templates' => count($this->entries), 'fields' => 0, 'exporter_templates' => 0, 'exporter_fields' => 0]; + foreach ($this->entries as $entry) { + $fields = count($entry['template'][0]); + $usage['fields'] += $fields; + if ($entry['router'] === $router) { + $usage['exporter_templates']++; + $usage['exporter_fields'] += $fields; + } + } + return $usage; + } +} + /** * Decode network-order counters without rounding unsigned 64-bit values. * @@ -33,6 +108,9 @@ * @return int|string */ function fv_nbar_uint($s) { + if (strlen($s) > 8) { + throw new InvalidArgumentException('NBAR numeric fields must fit an unsigned 64-bit value'); + } $n = 0; foreach (unpack('C*', $s) as $v) { if (is_int($n) && $n <= intdiv(PHP_INT_MAX - $v, 256)) { @@ -61,11 +139,14 @@ function fv_nbar_uint($s) { */ function fv_nbar_packet($p, $router) { global $listener_id; - static $templates = []; + static $templates = null; if (strlen($p) < 20 || fv_nbar_uint(substr($p, 0, 2)) !== 9) { return; } - $key = $router . ':' . bin2hex(substr($p, 16, 4)); + $templates ??= new FlowviewNbarTemplateCache(); + $now = time(); + $templates->prune($now); + $source = bin2hex(substr($p, 16, 4)); $unix = fv_nbar_uint(substr($p, 8, 4)); $uptime = fv_nbar_uint(substr($p, 4, 4)); $rows = []; @@ -94,7 +175,7 @@ function fv_nbar_packet($p, $router) { $q += 6; } if ($size < 4 || $size % 4 || $q + $size > $end) { - unset($templates[$key][$tid]); + $templates->remove($router, $source, $tid); break; } $fields = []; @@ -103,7 +184,7 @@ function fv_nbar_packet($p, $router) { for ($z = 0;$z < $size;$z += 4) { $f = fv_nbar_uint(substr($p, $q + $z, 2)); $l = fv_nbar_uint(substr($p, $q + $z + 2, 2)); - if ($l === 0) { + if ($l === 0 || ($id === 0 && in_array($f, [1, 2, 4, 7, 11, 21], true) && $l > 8)) { $valid = false; break; } @@ -111,14 +192,14 @@ function fv_nbar_packet($p, $router) { $total += $l; } if ($valid && $total > 0 && $total <= 65535) { - $templates[$key][$tid] = [$fields, $total, $id === 1]; + $templates->store($router, $source, $tid, [$fields, $total, $id === 1], $now); } else { - unset($templates[$key][$tid]); + $templates->remove($router, $source, $tid); } $q += $size; } - } elseif ($id >= 256 && isset($templates[$key][$id])) { - list($fields, $total, $options) = $templates[$key][$id]; + } elseif ($id >= 256 && ($template = $templates->get($router, $source, $id, $now)) !== null) { + list($fields, $total, $options) = $template; while ($q + $total <= $end) { $data = []; foreach ($fields as $f) { @@ -129,8 +210,8 @@ function fv_nbar_packet($p, $router) { continue; } $aid = bin2hex($data[95]); - if ($options && isset($data[96])) { - $name = trim($data[96], chr(0) . ' '); + if ($options) { + $name = trim($data[96] ?? '', chr(0) . ' '); if ($name !== '' && strlen($name) <= 255 && preg_match('/^[a-zA-Z0-9_.: -]+$/D', $name)) { flowview_db_execute_prepared('INSERT INTO plugin_flowview_nbar_names VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE name=VALUES(name),updated=VALUES(updated)', [$router, $aid, $name, time()]); } diff --git a/tests/Unit/NbarPacketTest.php b/tests/Unit/NbarPacketTest.php index 17c29eb6..c1b45aa3 100644 --- a/tests/Unit/NbarPacketTest.php +++ b/tests/Unit/NbarPacketTest.php @@ -97,6 +97,56 @@ $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'); + 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) { + $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, 4096]; + $data = pack('nn', $tid, 4 + strlen($record) + 4096) . $record . str_repeat('x', 4096); + fv_nbar_packet($this->header . $encode($invalid) . $data, $router); + fv_nbar_packet($this->header . $normal, $router); + expect($GLOBALS['__test_db_calls'])->toBe([]); +})->with([1, 2, 4, 7, 11, 21]); + +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/NbarTemplateCacheTest.php b/tests/Unit/NbarTemplateCacheTest.php new file mode 100644 index 00000000..79b1c4bd --- /dev/null +++ b/tests/Unit/NbarTemplateCacheTest.php @@ -0,0 +1,56 @@ +store('192.0.2.1', (string) $i, 256, $template, 100))->toBeTrue(); + } + expect($cache->get('192.0.2.1', '0', 256, 101))->toBe($template); + $cache->store('192.0.2.1', '64', 256, $template, 101); + expect($cache->get('192.0.2.1', '1', 256, 101))->toBeNull()->and($cache->get('192.0.2.1', '0', 256, 101))->toBe($template); + for ($i = 65;$i < 500;$i++) { + $cache->store('192.0.2.1', (string) $i, 256, $template, 101); + } + expect($cache->usage('192.0.2.1')['exporter_templates'])->toBe(64); +}); +it('bounds global template count independently of per-exporter churn', function () { + $cache = new FlowviewNbarTemplateCache(); + $template = [[[95, 4]], 4, false]; + for ($i = 0;$i < 300;$i++) { + $cache->store('192.0.2.' . ($i % 5), (string) $i, 256, $template, 100); + } + expect($cache->usage('192.0.2.0')['templates'])->toBe(256); + expect($cache->get('192.0.2.0', '0', 256, 101))->toBeNull(); +}); +it('bounds retained field definitions globally and per exporter and rejects oversized templates', function () { + $cache = new FlowviewNbarTemplateCache(); + $large = [array_fill(0, 2048, [1, 1]), 2048, false]; + $cache->store('192.0.2.1', 'a', 256, $large, 100); + $cache->store('192.0.2.1', 'b', 256, $large, 100); + expect($cache->get('192.0.2.1', 'a', 256, 101))->toBeNull(); + for ($i = 2;$i < 7;$i++) { + $cache->store('192.0.2.' . $i, 'a', 256, $large, 100); + } + expect($cache->usage('192.0.2.6')['fields'])->toBe(8192)->and($cache->usage('192.0.2.6')['exporter_fields'])->toBe(2048); + expect($cache->store('192.0.2.6', 'a', 256, [array_fill(0, 2049, [1, 1]), 2049, false], 101))->toBeFalse(); + expect($cache->get('192.0.2.6', 'a', 256, 102))->toBeNull(); +}); +it('expires inactive templates and refreshes activity on successful use', function () { + $cache = new FlowviewNbarTemplateCache(); + $template = [[[95, 4]], 4, false]; + $cache->store('192.0.2.1', 'a', 256, $template, 100); + $cache->store('192.0.2.1', 'b', 256, $template, 100); + $cache->get('192.0.2.1', 'a', 256, 1800); + $cache->prune(1900); + expect($cache->usage('192.0.2.1')['templates'])->toBe(1); + expect($cache->get('192.0.2.1','a',256,3600))->toBeNull(); +}); +it('rejects numeric values wider than unsigned 64-bit before conversion',function () { + expect(fn () => fv_nbar_uint(str_repeat('x',4096)))->toThrow(InvalidArgumentException::class); +}); From 628bec1b57f5941647a885d055f87f05a816809f Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:57:19 +1030 Subject: [PATCH 11/27] Validate numeric template widths against their data field types --- nbar_collector.php | 3 ++- tests/Unit/NbarPacketTest.php | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/nbar_collector.php b/nbar_collector.php index 04f468f3..83d70a65 100644 --- a/nbar_collector.php +++ b/nbar_collector.php @@ -184,7 +184,8 @@ function fv_nbar_packet($p, $router) { for ($z = 0;$z < $size;$z += 4) { $f = fv_nbar_uint(substr($p, $q + $z, 2)); $l = fv_nbar_uint(substr($p, $q + $z + 2, 2)); - if ($l === 0 || ($id === 0 && in_array($f, [1, 2, 4, 7, 11, 21], true) && $l > 8)) { + $width = [1 => 8, 2 => 8, 4 => 1, 7 => 2, 11 => 2, 21 => 4][$f] ?? null; + if ($l === 0 || ($id === 0 && $width !== null && $l > $width)) { $valid = false; break; } diff --git a/tests/Unit/NbarPacketTest.php b/tests/Unit/NbarPacketTest.php index c1b45aa3..3ea180f9 100644 --- a/tests/Unit/NbarPacketTest.php +++ b/tests/Unit/NbarPacketTest.php @@ -101,7 +101,7 @@ expect($GLOBALS['__test_db_calls'])->toBe([]); }); -it('invalidates data templates with oversized numeric fields before decoding', function ($field) { +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]]; @@ -114,12 +114,12 @@ fv_nbar_packet($this->header . $encode($fields) . $normal, $router); plugin_test_reset(); $invalid = $fields; - $invalid[] = [$field, 4096]; - $data = pack('nn', $tid, 4 + strlen($record) + 4096) . $record . str_repeat('x', 4096); + $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, 2, 4, 7, 11, 21]); +})->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); From d3dc9023ac0b41e756d92df53441559678ac1676 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:14:25 +1030 Subject: [PATCH 12/27] Localize conversation report details and retention text --- locales/po/cacti.pot | 5 +++++ nbar_conversations.php | 2 +- tests/Unit/NbarRenderingTest.php | 2 ++ tests/bootstrap-unit.php | 4 +++- 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 6573c2e3..855f1b50 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -3294,3 +3294,8 @@ msgstr "" #: nbar_feed_status.php msgid "Unable to load NBAR status. Please try again." msgstr "" + +#: nbar_conversations.php +#, php-format +msgid "Top 100 conversations by combined data volume. Each row groups an Internal Host, External Host and application across both directions. Uses only the selected LAN captures to avoid counting WAN copies. Local and multicast traffic is excluded. DNS names identify servers, not the exact website requested. History retention: %d days." +msgstr "" diff --git a/nbar_conversations.php b/nbar_conversations.php index 47864823..397f4b49 100644 --- a/nbar_conversations.php +++ b/nbar_conversations.php @@ -83,7 +83,7 @@ if (!$rows) { echo '' . html_escape(__('No conversations for this period. Allow 1–2 minutes for exports, or choose a longer timespan.', 'flowview')) . ''; } -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')) . '

    Top 100 conversations by combined data volume. Each row groups an Internal Host, External Host and application across both directions. Uses only the selected LAN captures to avoid counting WAN copies. Local and multicast traffic is excluded. DNS names identify servers, not the exact website requested. History retention: ' . fv_nbar_retention_days() . ' days.

    '; +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(__('Top 100 conversations by combined data volume. Each row groups an Internal Host, External Host and application across both directions. Uses only the selected LAN captures to avoid counting WAN copies. Local and multicast traffic is excluded. DNS names identify servers, not the exact website requested. History retention: %d days.', fv_nbar_retention_days(), 'flowview')) . '

    '; echo ''; echo ''; if ($mode === 'conversations') { - include __DIR__ . '/nbar_conversations.php'; + include(__DIR__ . '/nbar_conversations.php'); bottom_footer(); exit; } @@ -286,9 +292,7 @@ $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) { diff --git a/phpunit.xml b/phpunit.xml index 03f0a9bb..37f5c0e6 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -40,22 +40,22 @@ includes/functions.php includes/database.php includes/nbar_database.php - nbar_chart.php - nbar_collector.php - nbar_common.php - nbar_connections.php + includes/nbar_chart.php + includes/nbar_collector.php + includes/nbar_common.php + includes/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 + includes/nbar_count.php + includes/nbar_daily.php + includes/nbar_dns.php + includes/nbar_feed_status.php + includes/nbar_preferences.php + includes/nbar_query.php nbar_report.php - nbar_seen.php + includes/nbar_seen.php nbar_setup_guide.php - nbar_summary.php - nbar_timespan.php + includes/nbar_summary.php + includes/nbar_timespan.php flowview_sections.php diff --git a/setup.php b/setup.php index 9a78d939..96956531 100644 --- a/setup.php +++ b/setup.php @@ -23,6 +23,8 @@ +-------------------------------------------------------------------------+ */ +require_once(__DIR__ . '/includes/nbar_common.php'); + /** * Return the CSP nonce attribute for inline '; - echo ''; - + print '' . 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')) . ''; + print '

    ' . 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')) . '

    '; + print ''; + print ''; } /** Exclude multicast endpoints from internal/external conversations. */ @@ -178,5 +196,6 @@ 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/includes/nbar_query.php b/includes/nbar_query.php index bf9c0857..20189044 100644 --- a/includes/nbar_query.php +++ b/includes/nbar_query.php @@ -26,8 +26,8 @@ /** * Fetch a bounded report result with a short account-session cache. * - * @param string $sql Trusted SQL statement. - * @param array $args Prepared statement values. + * @param string $sql Trusted SQL statement. + * @param array $args Prepared statement values. * * @return array|false */ @@ -37,25 +37,30 @@ function fv_nbar_query_cached(string $sql, array $args): array|false { } // Fetch five adjacent pages together; retain one extra row for Next. $slice = null; + if (preg_match('/ LIMIT 101 OFFSET ([0-9]+)(?=\)|$)/', $sql, $match)) { $offset = (int) $match[1]; - $block = (int) (floor($offset / 500) * 500); - $slice = $offset - $block; - $sql = preg_replace('/ LIMIT 101 OFFSET [0-9]+(?=\)|$)/', ' LIMIT 501 OFFSET ' . $block, $sql); + $block = (int) (floor($offset / 500) * 500); + $slice = $offset - $block; + $sql = preg_replace('/ LIMIT 101 OFFSET [0-9]+(?=\)|$)/', ' LIMIT 501 OFFSET ' . $block, $sql); } - $now = time(); - $key = hash('sha256', serialize([$_SESSION['sess_user_id'] ?? 0, $sql, $args])); + $now = time(); + $key = hash('sha256', serialize([$_SESSION['sess_user_id'] ?? 0, $sql, $args])); $cache = $_SESSION['fv_nbar_query_cache'] ?? []; + foreach ($cache as $id => $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) { @@ -64,6 +69,7 @@ function fv_nbar_query_cached(string $sql, array $args): array|false { $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); } @@ -71,15 +77,16 @@ function fv_nbar_query_cached(string $sql, array $args): array|false { * 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. + * @param string $where Validated filter predicate. + * @param int $page One-based connection page. * * @return string */ function fv_nbar_connections_sql(string $source, string $where, int $page): string { // 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)'; + $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/includes/nbar_seen.php b/includes/nbar_seen.php index 0d7a53b6..1350d79e 100644 --- a/includes/nbar_seen.php +++ b/includes/nbar_seen.php @@ -32,31 +32,34 @@ */ function fv_nbar_seen_cells(array $row): string { $html = ''; + foreach (['first_seen', 'last_seen'] as $field) { $stamp = (int) ($row[$field] ?? 0); - $text = $stamp > 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'); + $text = $stamp > 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. + * @param array $row Exported tuple or observation fields. * * @return void */ function fv_nbar_seen_merge(array &$total, array $row): void { if (!isset($row['first_seen'],$row['last_seen'])) { $total['_seen_missing'] = true; + return; } - $first = (int) $row['first_seen']; - $last = (int) $row['last_seen']; + $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); + $total['last_seen'] = $total['last_seen'] === null ? $last : max($total['last_seen'], $last); } /** * Install observation columns and resumable backfill checkpoints. @@ -67,6 +70,7 @@ function fv_nbar_seen_schema(): bool { 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) { @@ -77,23 +81,28 @@ function fv_nbar_seen_schema(): bool { } } $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; } /** @@ -107,92 +116,113 @@ function fv_nbar_seen_backfill(int $budget = 10): int { 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; + $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 = ''; + $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]); + $end = (int) flowview_db_fetch_cell_prepared('SELECT covered_to FROM plugin_flowview_nbar_days WHERE listener_id=? AND day=?', [$listener, $period]); } - $ok = true; + $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'"); + $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 (string $k) use ($collation): string {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)"; + $join = implode(' AND ', array_map(static function (string $k) use ($collation): string {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; + $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; } @@ -204,13 +234,16 @@ function fv_nbar_seen_backfill(int $budget = 10): int { 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/includes/nbar_summary.php b/includes/nbar_summary.php index 0bf6cc00..011bd862 100644 --- a/includes/nbar_summary.php +++ b/includes/nbar_summary.php @@ -37,19 +37,21 @@ function fv_nbar_summary_schema(): bool { } } $column = flowview_db_fetch_row("SHOW COLUMNS FROM plugin_flowview_nbar_hours LIKE 'connection_built'"); + if (!$column && flowview_db_add_column('plugin_flowview_nbar_hours', ['name' => '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. + * @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 */ @@ -58,17 +60,22 @@ function fv_nbar_summary_store(string $sql, int $listener, array $hours, array $ 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; } @@ -81,34 +88,42 @@ function fv_nbar_summary_store(string $sql, int $listener, array $hours, array $ */ function fv_nbar_summary_refresh(int $budget = 20, bool $supervised = false): int { $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; + $done = 0; $started = microtime(true); + try { - $cutoff = time() - fv_nbar_retention_days() * 86400; - $closed = (int) (floor((time() - 300) / 3600) * 3600); + $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']; + $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"); + $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++; } @@ -116,6 +131,7 @@ function fv_nbar_summary_refresh(int $budget = 20, bool $supervised = false): in } 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); } @@ -123,54 +139,64 @@ function fv_nbar_summary_refresh(int $budget = 20, bool $supervised = false): in * 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. + * @param int $hour UTC hour boundary in Unix seconds. * * @return bool */ function fv_nbar_summary_build(int $listener, int $hour, bool $supervised = false): bool { // 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', + 'apps' => '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) { @@ -182,11 +208,11 @@ function fv_nbar_summary_build(int $listener, int $hour, bool $supervised = fals /** * 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. + * @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 */ @@ -194,33 +220,40 @@ function fv_nbar_hourly_source(string $kind, array $listeners, int $start, int $ 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 (int $id): bool {return $id > 0;}))); + $listeners = array_values(array_unique(array_filter(array_map('intval', $listeners), static function (int $id): bool {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; + $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)); + $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 = []; + $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); + $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"; + $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 { @@ -230,5 +263,6 @@ function fv_nbar_hourly_source(string $kind, array $listeners, int $start, int $ $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/includes/nbar_timespan.php b/includes/nbar_timespan.php index fd780ccb..1d41b552 100644 --- a/includes/nbar_timespan.php +++ b/includes/nbar_timespan.php @@ -26,21 +26,23 @@ /** * Limit preset choices to the configured retention window. * - * @param array $presets Cacti timespan labels by identifier. - * @param int $now Current Unix time. - * @param int $days Configured retention in days. + * @param array $presets Cacti timespan labels by identifier. + * @param int $now Current Unix time. + * @param int $days Configured retention in days. * @param int|string $weekday Cacti first-weekday setting. * * @return array */ function fv_nbar_timespan_options(array $presets, int $now, int $days, int|string $weekday): array { $options = []; - $cutoff = $now - $days * 86400; + $cutoff = $now - $days * 86400; + foreach ($presets as $id => $label) { $span = []; get_timespan($span, $now, $id, $weekday); $from = strtotime($span['current_value_date1']); - $to = strtotime($span['current_value_date2']); + $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) { @@ -50,25 +52,28 @@ function fv_nbar_timespan_options(array $presets, int $now, int $days, int|strin } } $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. - * @param int $days Configured retention in days. + * @param int $end Exclusive range end in Unix seconds. + * @param int $now Current Unix time. + * @param int $days Configured retention in days. * * @return array */ function fv_nbar_bound_dates(int $start, int $end, int $now, int $days): array { $cutoff = $now - $days * 86400; - $from = max($cutoff, $start); - $to = min($now, $end); + $from = max($cutoff, $start); + $to = min($now, $end); + if ($to <= $from) { return [$now - 1800, $now, true]; } + return [$from, $to, $from !== $start || $to !== $end]; } @@ -78,12 +83,14 @@ function fv_nbar_timespan_request(): bool { return false; } header('Content-Type: application/json'); + if ((int) get_filter_request_var('predefined_timespan') === 1000) { - $end = (int) (floor(time() / 60) * 60); + $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/locales/po/cacti.pot b/locales/po/cacti.pot index 855f1b50..0ee096ca 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 5.1\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-17 22:46+1030\n" +"POT-Creation-Date: 2026-10-19 12:10-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -518,7 +518,7 @@ msgstr[0] "" msgstr[1] "" #: flowview_databases.php flowview_devices.php flowview_filters.php -#: flowview_schedules.php includes/functions.php nbar_preferences.php +#: flowview_schedules.php includes/functions.php includes/nbar_preferences.php msgid "Cancel" msgstr "" @@ -1926,7 +1926,7 @@ msgstr "" msgid "Edit the Flow Filter" msgstr "" -#: includes/functions.php nbar_preferences.php +#: includes/functions.php includes/nbar_preferences.php msgid "Save" msgstr "" @@ -2417,202 +2417,237 @@ msgstr "" msgid "InnoDB (Slow, High Concurrency)" msgstr "" -#: nbar_chart.php +#: includes/nbar_chart.php +msgid "Unable to load application chart. Please try again." +msgstr "" + +#: includes/nbar_chart.php msgid "Other" msgstr "" -#: nbar_chart.php +#: includes/nbar_chart.php msgid "Top 5 applications" msgstr "" -#: nbar_chart.php +#: includes/nbar_chart.php msgid "Top 5 applications by data" msgstr "" -#: nbar_chart.php +#: includes/nbar_chart.php msgid "Application data share for the selected period" msgstr "" -#: nbar_chart.php +#: includes/nbar_chart.php msgid "No application data in this period" msgstr "" -#: nbar_conversations.php -msgid "Configure your site networks in Application settings to label internal and external devices. Top applications and Connections work directly with exported IPv4 and IPv6 addresses." +#: includes/nbar_dns.php +msgid "No reverse DNS" msgstr "" -#: nbar_conversations.php nbar_report.php -msgid "← Back to top applications" +#: includes/nbar_dns.php +msgid "Not resolved" msgstr "" -#: nbar_conversations.php -msgid "Internal Host ↔ External Host. Click a device to see its individual connections. Bandwidth is averaged over the selected timespan." +#: includes/nbar_feed_status.php +msgid "STATUS:" msgstr "" -#: nbar_conversations.php nbar_report.php -msgid "Application" +#: includes/nbar_feed_status.php +msgid "Unable to load NBAR status. Please try again." msgstr "" -#: nbar_conversations.php -msgid "Download (Avg Mbps)" +#: includes/nbar_feed_status.php +msgid "No recent NBAR" msgstr "" -#: nbar_conversations.php -msgid "Download (GB)" +#: includes/nbar_feed_status.php +msgid "Receiving" msgstr "" -#: nbar_conversations.php -msgid "External Host" +#: includes/nbar_feed_status.php +#, php-format +msgid "Last NBAR record: %s" msgstr "" -#: nbar_conversations.php nbar_report.php -msgid "First seen" +#: includes/nbar_feed_status.php +msgid "No retained NBAR records" msgstr "" -#: nbar_conversations.php -msgid "Internal Host" +#: includes/nbar_feed_status.php +msgid "No captures selected" msgstr "" -#: nbar_conversations.php nbar_report.php -msgid "Last seen" +#: includes/nbar_preferences.php +msgid "Settings were not saved. Enter valid IPv4/IPv6 networks, or leave the list blank." msgstr "" -#: nbar_conversations.php -msgid "Upload (Avg Mbps)" +#: includes/nbar_preferences.php +msgid "Settings were not saved. Choose two or three captures to combine, or clear all selections to save Internal Networks only." msgstr "" -#: nbar_conversations.php -msgid "Upload (GB)" +#: includes/nbar_preferences.php +msgid "Bandwidth units" msgstr "" -#: nbar_conversations.php nbar_report.php -#, php-format -msgid "Awaiting name (%s)" +#: includes/nbar_preferences.php +msgid "DNS names" msgstr "" -#: nbar_conversations.php -msgid "No conversations for this period. Allow 1–2 minutes for exports, or choose a longer timespan." +#: includes/nbar_preferences.php +msgid "Data units" msgstr "" -#: nbar_conversations.php nbar_report.php -msgid "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." +#: includes/nbar_preferences.php +msgid "Hide" msgstr "" -#: nbar_conversations.php nbar_report.php -msgid "Report information" +#: includes/nbar_preferences.php nbar_report.php +msgid "Show" msgstr "" -#: nbar_conversations.php nbar_report.php -msgid "Drag to resize column" +#: includes/nbar_preferences.php +msgid "Auto" msgstr "" -#: nbar_dns.php -msgid "No reverse DNS" +#: includes/nbar_preferences.php nbar_setup_guide.php +msgid "Close" msgstr "" -#: nbar_dns.php -msgid "Not resolved" +#: includes/nbar_preferences.php +msgid "Combine Options" msgstr "" -#: nbar_feed_status.php -msgid "STATUS:" +#: includes/nbar_preferences.php nbar_report.php +msgid "Options" msgstr "" -#: nbar_feed_status.php -msgid "No recent NBAR" +#: includes/nbar_preferences.php +msgid "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." msgstr "" -#: nbar_feed_status.php -msgid "Receiving" +#: includes/nbar_preferences.php +msgid "Internal Networks" +msgstr "" + +#: includes/nbar_preferences.php +msgid "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." +msgstr "" + +#: includes/nbar_preferences.php +msgid " — combining off" msgstr "" -#: nbar_feed_status.php +#: includes/nbar_preferences.php +msgid " — select another capture or untick all to save" +msgstr "" + +#: includes/nbar_preferences.php +msgid "Average rate across the selected timespan." +msgstr "" + +#: includes/nbar_preferences.php +msgid "Decimal units: 1 GB = 1,000 MB." +msgstr "" + +#: includes/nbar_preferences.php +msgid "{count} of 3 selected" +msgstr "" + +#: includes/nbar_seen.php +msgid "Unavailable" +msgstr "" + +#: includes/nbar_seen.php #, php-format -msgid "Last NBAR record: %s" +msgid "FlowView observation within the selected period; minute precision. Time zone: %s" msgstr "" -#: nbar_feed_status.php -msgid "No retained NBAR records" +#: includes/nbar_timespan.php +#, php-format +msgid "Last %s Days" msgstr "" -#: nbar_feed_status.php -msgid "No captures selected" +#: includes/nbar_timespan.php +msgid "Last Day" msgstr "" -#: nbar_preferences.php -msgid "Settings were not saved. Enter valid IPv4/IPv6 networks, or leave the list blank." +#: nbar_conversations.php +msgid "Configure your site networks in Application settings to label internal and external devices. Top applications and Connections work directly with exported IPv4 and IPv6 addresses." msgstr "" -#: nbar_preferences.php -msgid "Settings were not saved. Choose two or three captures to combine, or clear all selections to save Internal Networks only." +#: nbar_conversations.php +msgid "Unable to load conversations. Please try again." msgstr "" -#: nbar_preferences.php -msgid "Bandwidth units" +#: nbar_conversations.php nbar_report.php +msgid "← Back to top applications" msgstr "" -#: nbar_preferences.php -msgid "DNS names" +#: nbar_conversations.php +msgid "Internal Host ↔ External Host. Click a device to see its individual connections. Bandwidth is averaged over the selected timespan." msgstr "" -#: nbar_preferences.php -msgid "Data units" +#: nbar_conversations.php nbar_report.php +msgid "Application" msgstr "" -#: nbar_preferences.php -msgid "Hide" +#: nbar_conversations.php +msgid "Download (Avg Mbps)" msgstr "" -#: nbar_preferences.php nbar_report.php -msgid "Show" +#: nbar_conversations.php +msgid "Download (GB)" msgstr "" -#: nbar_preferences.php -msgid "Auto" +#: nbar_conversations.php +msgid "External Host" msgstr "" -#: nbar_preferences.php nbar_setup_guide.php -msgid "Close" +#: nbar_conversations.php nbar_report.php +msgid "First seen" msgstr "" -#: nbar_preferences.php -msgid "Combine Options" +#: nbar_conversations.php +msgid "Internal Host" msgstr "" -#: nbar_preferences.php nbar_report.php -msgid "Options" +#: nbar_conversations.php nbar_report.php +msgid "Last seen" msgstr "" -#: nbar_preferences.php -msgid "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." +#: nbar_conversations.php +msgid "Upload (Avg Mbps)" msgstr "" -#: nbar_preferences.php -msgid "Internal Networks" +#: nbar_conversations.php +msgid "Upload (GB)" msgstr "" -#: nbar_preferences.php -msgid "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." +#: nbar_conversations.php nbar_report.php +#, php-format +msgid "Awaiting name (%s)" msgstr "" -#: nbar_preferences.php -msgid " — combining off" +#: nbar_conversations.php +msgid "No conversations for this period. Allow 1–2 minutes for exports, or choose a longer timespan." msgstr "" -#: nbar_preferences.php -msgid " — select another capture or untick all to save" +#: nbar_conversations.php nbar_report.php +msgid "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." msgstr "" -#: nbar_preferences.php -msgid "Average rate across the selected timespan." +#: nbar_conversations.php nbar_report.php +msgid "Report information" msgstr "" -#: nbar_preferences.php -msgid "Decimal units: 1 GB = 1,000 MB." +#: nbar_conversations.php +#, php-format +msgid "Top 100 conversations by combined data volume. Each row groups an Internal Host, External Host and application across both directions. Uses only the selected LAN captures to avoid counting WAN copies. Local and multicast traffic is excluded. DNS names identify servers, not the exact website requested. History retention: %d days." msgstr "" -#: nbar_preferences.php -msgid "{count} of 3 selected" +#: nbar_conversations.php nbar_report.php +msgid "Drag to resize column" msgstr "" #: nbar_report.php @@ -2685,6 +2720,10 @@ msgstr "" msgid "Unable to load connections. Try a shorter timespan or filter by IP." msgstr "" +#: nbar_report.php +msgid "Unable to load applications. Please try again." +msgstr "" + #: nbar_report.php msgid "Counting total pages…" msgstr "" @@ -2762,15 +2801,6 @@ msgstr "" msgid "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." msgstr "" -#: nbar_seen.php -msgid "Unavailable" -msgstr "" - -#: nbar_seen.php -#, php-format -msgid "FlowView observation within the selected period; minute precision. Time zone: %s" -msgstr "" - #: nbar_setup_guide.php msgid "1. FlowView listener" msgstr "" @@ -2851,15 +2881,6 @@ msgstr "" msgid "Reading reports" msgstr "" -#: nbar_timespan.php -#, php-format -msgid "Last %s Days" -msgstr "" - -#: nbar_timespan.php -msgid "Last Day" -msgstr "" - #: setup.php msgid "NetFlow User" msgstr "" @@ -3278,24 +3299,3 @@ msgstr "" #: setup.php msgid "NetFlow" msgstr "" - -#: nbar_chart.php -msgid "Unable to load application chart. Please try again." -msgstr "" - -#: nbar_chart.php -msgid "Unable to load conversations. Please try again." -msgstr "" - -#: nbar_report.php -msgid "Unable to load applications. Please try again." -msgstr "" - -#: nbar_feed_status.php -msgid "Unable to load NBAR status. Please try again." -msgstr "" - -#: nbar_conversations.php -#, php-format -msgid "Top 100 conversations by combined data volume. Each row groups an Internal Host, External Host and application across both directions. Uses only the selected LAN captures to avoid counting WAN copies. Local and multicast traffic is excluded. DNS names identify servers, not the exact website requested. History retention: %d days." -msgstr "" diff --git a/nbar_conversations.php b/nbar_conversations.php index 397f4b49..16357e53 100644 --- a/nbar_conversations.php +++ b/nbar_conversations.php @@ -25,18 +25,22 @@ // Conversation direction uses configured site networks and selected listeners. if (empty($nbar_preferences['prefixes'])) { - echo '

    ' . html_escape(__('Configure your site networks in Application settings to label internal and external devices. Top applications and Connections work directly with exported IPv4 and IPv6 addresses.', 'flowview')) . '

    '; + print '

    ' . html_escape(__('Configure your site networks in Application settings to label internal and external devices. Top applications and Connections work directly with exported IPv4 and IPv6 addresses.', 'flowview')) . '

    '; + return; } $where = $listener_scope . ' AND d.minute>=? AND d.minute' . fv_nbar_h(__('Unable to load conversations. Please try again.', 'flowview')) . '

    '; + print '

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

    '; + return; } $convbase = $base . '&mode=conversations' . ($aid !== '' ? '&app=' . $aid : ''); + if ($aid !== '') { - echo '

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

    '; + print '

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

    '; } -echo '

    ' . html_escape(__('Internal Host ↔ External Host. Click a device to see its individual connections. Bandwidth is averaged over the selected timespan.', 'flowview')) . '

    '; -echo '
    '; +print '

    ' . html_escape(__('Internal Host ↔ External Host. Click a device to see its individual connections. Bandwidth is averaged over the selected timespan.', 'flowview')) . '

    '; +print '
    '; + foreach ([__('Internal Host', 'flowview'), __('External Host', 'flowview'), __('Application', 'flowview'), __('Download (GB)', 'flowview'), __('Upload (GB)', 'flowview'), __('Download (Avg Mbps)', 'flowview'), __('Upload (Avg Mbps)', 'flowview'), __('First seen', 'flowview'), __('Last seen', 'flowview')] as $index => $h) { $unit = [3 => 'data', 4 => 'data', 5 => 'rate', 6 => 'rate'][$index] ?? ''; - echo ''; + print ''; } -echo ''; +print ''; $i = 0; + foreach ($rows as $r) { $link = $base . '&parent_app=' . rawurlencode($aid) . '&mode=connections&app=' . $r['app_id'] . '&internal=' . rawurlencode($r['internal']) . '&external=' . rawurlencode($r['external']); - echo ''; + print ''; + foreach (['internal', 'external'] as $f) { $dns = fv_nbar_dns($r[$f]); - echo ''; + print ''; } - echo ''; + print ''; + foreach ([$r['download'] / 1e9, $r['upload'] / 1e9, $r['download'] * 8 / $seconds / 1e6, $r['upload'] * 8 / $seconds / 1e6] as $j => $v) { $precision = $j < 2 ? 4 : 3; $threshold = pow(10, -$precision); - echo ''; + print ''; } - echo fv_nbar_seen_cells($r) . ''; + print fv_nbar_seen_cells($r) . ''; } + if (!$rows) { - echo ''; + print ''; } -echo '
    ' . fv_nbar_h($h) . '' . fv_nbar_h($h) . '
    ' . fv_nbar_h($r[$f]) . '
    ' . fv_nbar_h($dns) . '
    ' . fv_nbar_h($r[$f]) . '
    ' . fv_nbar_h($dns) . '
    ' . fv_nbar_h($r['name'] ?? __('Awaiting name (%s)', $r['app_id'], 'flowview')) . '' . fv_nbar_h($r['name'] ?? __('Awaiting name (%s)', $r['app_id'], 'flowview')) . '' . ($v > 0 && $v < $threshold ? '<' . number_format($threshold, $precision) : number_format($v, $precision)) . '' . ($v > 0 && $v < $threshold ? '<' . number_format($threshold, $precision) : number_format($v, $precision)) . '
    ' . html_escape(__('No conversations for this period. Allow 1–2 minutes for exports, or choose a longer timespan.', 'flowview')) . '
    ' . html_escape(__('No conversations for this period. Allow 1–2 minutes for exports, or choose a longer timespan.', 'flowview')) . '
    ' . 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(__('Top 100 conversations by combined data volume. Each row groups an Internal Host, External Host and application across both directions. Uses only the selected LAN captures to avoid counting WAN copies. Local and multicast traffic is excluded. DNS names identify servers, not the exact website requested. History retention: %d days.', fv_nbar_retention_days(), 'flowview')) . '

    '; +print '
    ' . 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(__('Top 100 conversations by combined data volume. Each row groups an Internal Host, External Host and application across both directions. Uses only the selected LAN captures to avoid counting WAN copies. Local and multicast traffic is excluded. DNS names identify servers, not the exact website requested. History retention: %d days.', fv_nbar_retention_days(), 'flowview')) . '

    '; -echo ''; diff --git a/nbar_report.php b/nbar_report.php index 7fe1ba31..a9781e02 100644 --- a/nbar_report.php +++ b/nbar_report.php @@ -51,223 +51,257 @@ // 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; } } -$devices = flowview_db_fetch_assoc("SELECT id,name FROM plugin_flowview_devices WHERE enabled='on' ORDER BY id"); +$devices = flowview_db_fetch_assoc("SELECT id,name FROM plugin_flowview_devices WHERE enabled='on' ORDER BY id"); $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) ($nbar_request['listener'] ?? ($devices[0]['id'] ?? 0)); +$valid = array_column($devices, 'id'); + +if ($lid !== 0 && !in_array($lid, $valid, true)) { $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'] ?? ''); +$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))); +$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; +$end = (int) (floor(time() / 60) * 60) - 60; +$start = $end - $seconds; $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; +$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; } -$nbar_now = (int) (floor(time() / 60) * 60); +$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 = ''; +$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; + $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; + $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']); + $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; + $end = $custom_end; } else { - $end = (int) (floor(time() / 60) * 60); + $end = (int) (floor(time() / 60) * 60); $start = $end - $seconds; } $timespan = 0; - $custom = true; + $custom = true; } + if ($timespan === 0 && $has_dates) { $start = $custom_start; - $end = $custom_end; + $end = $custom_end; } + if (!empty($nbar_request['lock_range']) && $has_dates) { $start = $custom_start; - $end = $custom_end; + $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; + $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); + $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); +$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); +$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 ''; +print ''; fv_nbar_feed_status($devices, array_map('intval', $lid === 0 ? $combined_lids : [$lid])); -echo ''; -echo '
    '; -echo ''; +print ''; +print '
    '; +print ''; + foreach (['action' => 'view', 'report' => 'nbar', 'context' => $context_json, 'app' => $aid, 'parent_app' => $parent_app] as $key => $value) { - echo ''; + print ''; } + foreach (['internal', 'external'] as $f) { if ($mode === 'connections' && filter_var($nbar_request[$f] ?? '', FILTER_VALIDATE_IP)) { - echo ''; + print ''; } } -echo '
    ' . html_escape(__('Router / capture', 'flowview')) . '
    ' . html_escape(__('Router / capture', 'flowview')) . '' . html_escape(__('Clear', 'flowview')) . '
    '; -echo '
    ' . html_escape(__('IP version', 'flowview')) . '' . html_escape(__('Clear', 'flowview')) . '
    '; +print '
    ' . html_escape(__('IP version', 'flowview')) . '' . html_escape(__('Show', 'flowview')) . '
    '; -echo '
    ' . html_escape(__('Filter by IP', 'flowview')) . '
    '; -echo '
    ' . html_escape(__('Timespan', 'flowview')) . '' . html_escape(__('Show', 'flowview')) . '
    '; +print '
    ' . html_escape(__('Filter by IP', 'flowview')) . '
    '; +print '
    ' . html_escape(__('Timespan', 'flowview')) . '' . html_escape(__('From', 'flowview')) . '' . html_escape(__('To', 'flowview')) . '
    '; +print '
    ' . html_escape(__('From', 'flowview')) . '' . html_escape(__('To', 'flowview')) . '
    '; + if ($nbar_range_notice !== '') { - echo '

    ' . fv_nbar_h($nbar_range_notice) . '

    '; + print '

    ' . fv_nbar_h($nbar_range_notice) . '

    '; } fv_nbar_preferences_render($devices, $base, $mode, $aid, $parent_app); -echo ''; +print ''; fv_nbar_chart($listener_scope, $lid === 0 ? [$start, $end] : [$lid, $start, $end], $family, $host); -echo '
    '; +print '
    '; html_end_box(); include(__DIR__ . '/nbar_setup_guide.php'); +print ''; +print ''; +print ''; - - -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')) . '

    '; + print '

    ' . 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) . '

    '; + print '

    ' . 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'; +$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') { $nbar_count_key = fv_nbar_count_context('', $where, $args, $end, $lid === 0 ? $combined_lids : [$lid], $start); + 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')) . '

    '; + print '

    ' . 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')) . '

    '; + print '

    ' . 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)); + $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; + print $nbar_pagination; } + if ($mode === 'connections' && $aid !== '') { $selected_name = $rows[0]['name'] ?? __('Application %s', $aid, 'flowview'); - echo '

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

    '; + print '

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

    '; } -echo '
    '; +print '
    ' . html_escape(__('Application', 'flowview')) . '
    '; + if ($mode === 'connections') { - echo ''; + print ''; } -echo ''; +print ''; $nbar_row = 0; + foreach ($rows as $r) { $name = $r['name'] ?? __('Awaiting name (%s)', $r['app_id'], 'flowview'); - echo ''; + print ''; if ($mode === 'connections') { foreach (['src', 'sport', 'dst', 'dport'] as $f) { - echo ''; + print ''; + if ($f === 'src' || $f === 'dst') { - echo ''; + print ''; } } - echo ''; + print ''; } - echo '' . fv_nbar_seen_cells($r) . ''; + print '' . fv_nbar_seen_cells($r) . ''; } + if (!$rows) { - echo ''; + print ''; } -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(__('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')) . '
    ' . 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')) . '
    '; + print '
    '; + if ($mode === 'connections') { - echo fv_nbar_h($name); + print fv_nbar_h($name); } else { - echo '' . fv_nbar_h($name) . ''; + print '' . fv_nbar_h($name) . ''; } - echo '' . fv_nbar_h($r[$f]) . '' . fv_nbar_h($r[$f]) . '' . fv_nbar_h(fv_nbar_dns($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']) . '' . 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']) . '
    ' . ($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')) . '
    ' . html_escape(__('No application records in this view yet. Try a longer period or another traffic view.', 'flowview')) . '
    '; -echo $nbar_pagination; +print ''; +print $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_setup_guide.php b/nbar_setup_guide.php index 6ad50e13..3b68e19b 100644 --- a/nbar_setup_guide.php +++ b/nbar_setup_guide.php @@ -95,4 +95,4 @@ interface VlanXX


    Copyright (c) 2004-2026 The Cacti Group. '; +print ''; diff --git a/setup.php b/setup.php index 96956531..6c18e4fc 100644 --- a/setup.php +++ b/setup.php @@ -49,11 +49,11 @@ function plugin_flowview_csp_nonce(): string { * @return string Escaped attribute value. */ function plugin_flowview_escape_attr(mixed $value): string { - if (function_exists('html_escape_attr')) { - return html_escape_attr($value); - } + if (function_exists('html_escape_attr')) { + return html_escape_attr($value); + } - return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } /** @@ -166,7 +166,7 @@ function plugin_flowview_check_upgrade(bool $force = false): void { 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/cli/flowview_upgrade.php'); + exec_background($php_binary, '-q ' . $config['base_path'] . '/plugins/flowview/cli/flowview_upgrade.php'); raise_message('flowview_upgrade', __('Please be advised the Flowview plugins Tables are being upgraded in the background. This may take some time. Check the Cacti log for more information'), MESSAGE_LEVEL_INFO); diff --git a/tests/Unit/NbarBackfillTest.php b/tests/Unit/NbarBackfillTest.php index 40d14b33..278ec531 100644 --- a/tests/Unit/NbarBackfillTest.php +++ b/tests/Unit/NbarBackfillTest.php @@ -1,5 +1,5 @@ 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')); + $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'); @@ -37,7 +40,7 @@ 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']; + $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]); @@ -49,6 +52,7 @@ 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); @@ -70,6 +74,7 @@ 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']); } @@ -80,7 +85,11 @@ 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']; }); + 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'); diff --git a/tests/Unit/NbarBehaviorTest.php b/tests/Unit/NbarBehaviorTest.php index 0a86483b..b04fc3c5 100644 --- a/tests/Unit/NbarBehaviorTest.php +++ b/tests/Unit/NbarBehaviorTest.php @@ -1,5 +1,5 @@ 4, $length === 4 ? 8 : 27 => $length, $length === 4 ? 12 : 28 => $length, 7 => 2, 11 => 2, 4 => 1, 1 => 4, 2 => 4]; + $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; + $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); + $call = end($calls); expect($call['conn'])->toBe('nbar-test-connection'); expect($call['params'][2])->toBe('0d000001'); expect($call['params'][3])->toBe(bin2hex($src)); @@ -82,12 +85,11 @@ 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); + $_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(''); @@ -98,12 +100,12 @@ 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]]; + $_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); + $saved = fv_nbar_preferences_init($devices); expect($GLOBALS['fv_nbar_preferences_saved'])->toBeTrue(); expect($saved['listeners'])->toBe([]); expect($saved['prefixes'])->toBe(['2001:db8::/64']); @@ -118,12 +120,10 @@ 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'); @@ -131,30 +131,31 @@ 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 = ''; + $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; + $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]; } diff --git a/tests/Unit/NbarBoundaryTest.php b/tests/Unit/NbarBoundaryTest.php index 1cb9ba25..639b3578 100644 --- a/tests/Unit/NbarBoundaryTest.php +++ b/tests/Unit/NbarBoundaryTest.php @@ -1,5 +1,5 @@ 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') { @@ -45,7 +46,7 @@ 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'); + $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); }); @@ -54,7 +55,7 @@ $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' => []]; + $_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'); @@ -62,8 +63,8 @@ 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([]); + $_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'); diff --git a/tests/Unit/NbarConnectionPagingTest.php b/tests/Unit/NbarConnectionPagingTest.php index fde49f88..4b25af07 100644 --- a/tests/Unit/NbarConnectionPagingTest.php +++ b/tests/Unit/NbarConnectionPagingTest.php @@ -1,5 +1,5 @@ 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]; + $this->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']); + $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); } @@ -46,6 +47,7 @@ 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); } @@ -96,6 +98,7 @@ 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'); @@ -104,6 +107,7 @@ 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', []); } @@ -111,16 +115,17 @@ 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]; + $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']); } @@ -129,16 +134,18 @@ 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); + $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'); } @@ -146,6 +153,7 @@ 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); } diff --git a/tests/Unit/NbarCountRequestTest.php b/tests/Unit/NbarCountRequestTest.php index 8436d431..2e36ecf2 100644 --- a/tests/Unit/NbarCountRequestTest.php +++ b/tests/Unit/NbarCountRequestTest.php @@ -1,21 +1,24 @@ toBe($status); - $sql = implode(' ', array_column($result['calls'], 'sql')); + $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'); @@ -26,10 +29,10 @@ } 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); diff --git a/tests/Unit/NbarDailyWorkerTest.php b/tests/Unit/NbarDailyWorkerTest.php index 83bed7ed..8caae276 100644 --- a/tests/Unit/NbarDailyWorkerTest.php +++ b/tests/Unit/NbarDailyWorkerTest.php @@ -1,5 +1,5 @@ toBe(1); $calls = $GLOBALS['__test_db_calls']; - $sql = implode(' ', array_column($calls, 'sql')); + $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'))); @@ -38,6 +38,7 @@ 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') { @@ -92,7 +93,7 @@ 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:"); + 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 () { @@ -108,7 +109,11 @@ 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', 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')); @@ -118,7 +123,11 @@ 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_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')); @@ -134,6 +143,7 @@ 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 index db99454d..24d4b0c2 100644 --- a/tests/Unit/NbarDirectRequestTest.php +++ b/tests/Unit/NbarDirectRequestTest.php @@ -1,16 +1,16 @@ toBe(''); expect($result['status'])->toBe($expected); - } finally { foreach (glob($directory . '/*') as $file) { unlink($file); diff --git a/tests/Unit/NbarDnsTest.php b/tests/Unit/NbarDnsTest.php index 3f302cb2..b32682a2 100644 --- a/tests/Unit/NbarDnsTest.php +++ b/tests/Unit/NbarDnsTest.php @@ -1,21 +1,22 @@ $result]; } } @@ -55,6 +56,7 @@ public function query($ip, $type) { plugin_test_reset(); flowview_test_load(dirname(__DIR__, 2) . '/includes/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']; } @@ -68,6 +70,7 @@ public function query($ip, $type) { 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]); diff --git a/tests/Unit/NbarFailurePathsTest.php b/tests/Unit/NbarFailurePathsTest.php index f6a34e4c..ecea6e2e 100644 --- a/tests/Unit/NbarFailurePathsTest.php +++ b/tests/Unit/NbarFailurePathsTest.php @@ -1,5 +1,5 @@ 'nbar', 'query' => ['bad'], 'mode' => 'apps']); - $params = fv_section_params('flows'); + $params = fv_section_params('flows'); expect($params)->not->toHaveKey('report')->not->toHaveKey('query'); expect($params['section'])->toBe('flows'); }); @@ -52,6 +52,7 @@ 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); } @@ -63,6 +64,7 @@ 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); @@ -84,6 +86,7 @@ } 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); } @@ -96,6 +99,7 @@ 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', []); } diff --git a/tests/Unit/NbarLatestReviewTest.php b/tests/Unit/NbarLatestReviewTest.php index ec507cd5..587a3248 100644 --- a/tests/Unit/NbarLatestReviewTest.php +++ b/tests/Unit/NbarLatestReviewTest.php @@ -1,15 +1,17 @@ ['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; + $session = $_SESSION; ob_start(); expect(fv_nbar_timespan_request())->toBeTrue(); $payload = json_decode(ob_get_clean(), true); @@ -66,6 +68,7 @@ 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); diff --git a/tests/Unit/NbarMaintenanceTest.php b/tests/Unit/NbarMaintenanceTest.php index 29b2621d..9c6eb15b 100644 --- a/tests/Unit/NbarMaintenanceTest.php +++ b/tests/Unit/NbarMaintenanceTest.php @@ -1,5 +1,5 @@ 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'); } @@ -45,7 +46,7 @@ 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'); + $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'))); @@ -55,6 +56,7 @@ 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); } @@ -127,6 +129,7 @@ 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 '); @@ -146,11 +149,11 @@ } }); - 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); @@ -165,10 +168,10 @@ it('caches exact-count contexts separately by account and filter', function () { flowview_test_load(dirname(__DIR__, 2) . '/includes/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); + $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()); + $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); @@ -180,6 +183,7 @@ it('expires old count contexts and bounds the account cache', function () { flowview_test_load(dirname(__DIR__, 2) . '/includes/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]; } diff --git a/tests/Unit/NbarPacketTest.php b/tests/Unit/NbarPacketTest.php index 89720c18..65fee716 100644 --- a/tests/Unit/NbarPacketTest.php +++ b/tests/Unit/NbarPacketTest.php @@ -1,5 +1,5 @@ header = pack('nnNNNN', 9, 2, 100000, time(), 1, 902); + $this->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; + $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); @@ -30,17 +30,19 @@ })->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]; + $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(); + $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 { @@ -50,19 +52,22 @@ })->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]; + $fields = $reason === 'application' ? [8 => 4, 12 => 4, 1 => 4] : [95 => 4, 8 => $reason === 'address' ? 1 : 4, 12 => 4, 1 => 4]; $definitions = ''; - $record = ''; + $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; + $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 { @@ -71,12 +76,13 @@ })->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); + $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; @@ -94,55 +100,57 @@ 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); + $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; + $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 = $fields; $invalid[] = [$field, $width]; - $data = pack('nn', $tid, 4 + strlen($record) + $width) . $record . str_repeat('x', $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); + $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)); + $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); + $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'); + $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); + $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++) { + $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'); diff --git a/tests/Unit/NbarRenderingTest.php b/tests/Unit/NbarRenderingTest.php index 3ded9f95..b64191f8 100644 --- a/tests/Unit/NbarRenderingTest.php +++ b/tests/Unit/NbarRenderingTest.php @@ -1,5 +1,5 @@ 1, 'name' => ''], ['id' => 2, 'name' => 'Second'], - ['id' => 3, 'name' => 'Third'], ['id' => 4, 'name' => 'Excluded'], + ['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'); @@ -52,9 +52,9 @@ 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' => 'Two', 'bytes' => 4000000], ['name' => 'Three', 'bytes' => 3000000], ['name' => 'Four', 'bytes' => 2000000], ['name' => 'Five', 'bytes' => 1000000], - ['name' => 'Six', 'bytes' => 500000], ['name' => 'Zero', 'bytes' => 0], + ['name' => 'Six', 'bytes' => 500000], ['name' => 'Zero', 'bytes' => 0], ]); ob_start(); fv_nbar_chart('d.listener_id=?', [1, 1800000000, 1800001800], 'all'); @@ -83,7 +83,7 @@ }); it('escapes names, prefixes and dynamic settings in the Options markup', function () { - $GLOBALS['fv_nbar_preferences'] = ['prefixes' => ['192.0.2.0/24'], 'listeners' => [1, 2], 'data_unit' => 'Auto', 'rate_unit' => 'Mbps', 'dns' => '0']; + $GLOBALS['fv_nbar_preferences'] = ['prefixes' => ['192.0.2.0/24'], 'listeners' => [1, 2], 'data_unit' => 'Auto', 'rate_unit' => 'Mbps', 'dns' => '0']; $GLOBALS['fv_nbar_preferences_message'] = ''; ob_start(); fv_nbar_preferences_render([['id' => 1, 'name' => ''], ['id' => 2, 'name' => 'Second']], '', '', '', ''); @@ -93,21 +93,20 @@ expect(substr_count($html, ' checked'))->toBe(2); }); - it('labels conversation directions, observation dates and the application drilldown', function () { $GLOBALS['__test_translations']['flowview']['Top 100 conversations by combined data volume. Each row groups an Internal Host, External Host and application across both directions. Uses only the selected LAN captures to avoid counting WAN copies. Local and multicast traffic is excluded. DNS names identify servers, not the exact website requested. History retention: %d days.'] = ' retention %d days.'; - $GLOBALS['fv_nbar_preferences'] = ['prefixes' => ['192.0.2.0/24'], 'dns' => '0']; - $nbar_preferences = $GLOBALS['fv_nbar_preferences']; - $listener_scope = 'd.listener_id=?'; - $lid = 1; - $combined_lids = []; - $start = 1800000000; - $end = 1800001800; - $seconds = 1800; - $family = '4'; - $aid = '0d000001'; - $host = '192.0.2.10'; - $base = 'flowview.php?report=nbar'; + $GLOBALS['fv_nbar_preferences'] = ['prefixes' => ['192.0.2.0/24'], 'dns' => '0']; + $nbar_preferences = $GLOBALS['fv_nbar_preferences']; + $listener_scope = 'd.listener_id=?'; + $lid = 1; + $combined_lids = []; + $start = 1800000000; + $end = 1800001800; + $seconds = 1800; + $family = '4'; + $aid = '0d000001'; + $host = '192.0.2.10'; + $base = 'flowview.php?report=nbar'; plugin_test_queue_db_result('db_fetch_assoc_prepared', [['app_id' => $aid, 'name' => '', 'internal' => $host, 'external' => '198.51.100.20', 'download' => 2000000, 'upload' => 10000, 'first_seen' => $start, 'last_seen' => $end - 60]]); ob_start(); include(dirname(__DIR__, 2) . '/nbar_conversations.php'); @@ -130,17 +129,17 @@ it('shows an empty conversation table when no matching records exist', function () { $GLOBALS['fv_nbar_preferences'] = ['prefixes' => ['2001:db8::/64'], 'dns' => '0']; - $nbar_preferences = $GLOBALS['fv_nbar_preferences']; - $listener_scope = 'd.listener_id=?'; - $lid = 1; - $combined_lids = []; - $start = 1800000000; - $end = 1800001800; - $seconds = 1800; - $family = 'all'; - $aid = ''; - $host = ''; - $base = 'flowview.php?report=nbar'; + $nbar_preferences = $GLOBALS['fv_nbar_preferences']; + $listener_scope = 'd.listener_id=?'; + $lid = 1; + $combined_lids = []; + $start = 1800000000; + $end = 1800001800; + $seconds = 1800; + $family = 'all'; + $aid = ''; + $host = ''; + $base = 'flowview.php?report=nbar'; ob_start(); include(dirname(__DIR__, 2) . '/nbar_conversations.php'); expect(ob_get_clean())->toContain('No conversations for this period'); @@ -151,7 +150,7 @@ fv_nbar_chart('d.listener_id=?', [1, 1800000000, 1800001800], 'all'); ob_end_clean(); $queries = array_column($GLOBALS['__test_db_calls'], 'sql'); - $sql = implode(' ', $queries); + $sql = implode(' ', $queries); expect($sql)->toContain("GROUP BY COALESCE(NULLIF(n.name,''),CONCAT('Application ',d.app_id)) ORDER BY bytes DESC"); expect($sql)->not->toContain('GROUP BY name ORDER BY'); }); diff --git a/tests/Unit/NbarReportPageTest.php b/tests/Unit/NbarReportPageTest.php index 0f0f06e9..43659ba6 100644 --- a/tests/Unit/NbarReportPageTest.php +++ b/tests/Unit/NbarReportPageTest.php @@ -1,5 +1,5 @@ '; + function html_start_box(mixed ...$args): void { + print ''; } } + if (!function_exists('html_end_box')) { - function html_end_box(...$args) { - echo '
    '; + function html_end_box(mixed ...$args): void { + print ''; } } + if (!function_exists('bottom_footer')) { - function bottom_footer() { + function bottom_footer(): void { $GLOBALS['__test_footer_rendered'] = true; } } - $GLOBALS['__test_config']['flowview_nbar_schema_version'] = '2'; + $GLOBALS['__test_config']['flowview_nbar_schema_version'] = '2'; $GLOBALS['__test_user_settings']['flowview_nbar_preferences'] = json_encode(['listeners' => [1, 2, 3], 'prefixes' => ['2001:db8::/64'], 'dns' => '0']); - $graph_timespans = [GT_LAST_HALF_HOUR => 'Last Half Hour', GT_LAST_HOUR => 'Last Hour', GT_LAST_DAY => 'Last Day', GT_LAST_WEEK => 'Last Week']; - $now = (int) floor(time() / 60) * 60; - $_REQUEST = ['mode' => $view, 'page' => '500', 'listener' => '0', 'family' => '6', 'host' => '2001:db8::10', 'app' => '0d000001', 'predefined_timespan' => '0', 'date1' => date('Y-m-d H:i', $now - 1200), 'date2' => date('Y-m-d H:i', $now), 'lock_range' => '1', 'context' => json_encode(['report' => 's10', 'unexpected' => ['nested']])]; - $_SERVER['REQUEST_METHOD'] = 'GET'; + $graph_timespans = [GT_LAST_HALF_HOUR => 'Last Half Hour', GT_LAST_HOUR => 'Last Hour', GT_LAST_DAY => 'Last Day', GT_LAST_WEEK => 'Last Week']; + $now = (int) floor(time() / 60) * 60; + $_REQUEST = ['mode' => $view, 'page' => '500', 'listener' => '0', 'family' => '6', 'host' => '2001:db8::10', 'app' => '0d000001', 'predefined_timespan' => '0', 'date1' => date('Y-m-d H:i', $now - 1200), 'date2' => date('Y-m-d H:i', $now), 'lock_range' => '1', 'context' => json_encode(['report' => 's10', 'unexpected' => ['nested']])]; + $_SERVER['REQUEST_METHOD'] = 'GET'; + for ($i = 0; $i < 3; $i++) { plugin_test_queue_db_result('db_fetch_assoc', []); } plugin_test_queue_db_result('db_fetch_assoc', [['id' => 1, 'name' => ''], ['id' => 2, 'name' => 'Second'], ['id' => 3, 'name' => 'Third']]); + for ($i = 0; $i < 6; $i++) { plugin_test_queue_db_result('db_fetch_row_prepared', ['minute' => $now - 60]); } plugin_test_queue_db_result('db_fetch_assoc_prepared', [['name' => '', 'bytes' => 1000000]]); + if ($view === 'connections') { for ($i = 0; $i < 7; $i++) { plugin_test_queue_db_result('db_fetch_assoc_prepared', []); @@ -64,6 +72,7 @@ function bottom_footer() { expect($GLOBALS['__test_footer_rendered'])->toBeTrue(); $sql = implode(' ', array_column($GLOBALS['__test_db_calls'], 'sql')); expect($sql)->toContain('LENGTH(d.src)=16', 'd.app_id=?', 'INET6_ATON(?)'); + if ($view === 'connections') { expect($html)->toContain('Source IP', 'Destination IP', 'TCP', 'Counting total pages', 'Previous', 'nbarPageNumber', 'Back to top applications'); expect($html)->not->toContain('All conversations'); diff --git a/tests/Unit/NbarReportRequestTest.php b/tests/Unit/NbarReportRequestTest.php index e7f8131c..2f174c47 100644 --- a/tests/Unit/NbarReportRequestTest.php +++ b/tests/Unit/NbarReportRequestTest.php @@ -1,27 +1,30 @@ toBe(''); expect($result['footer'])->toBeFalse(); parse_str(parse_url($result['redirect'], PHP_URL_QUERY), $redirect); expect($redirect['section'])->toBe($scenario === 'nav-redirect-fragment-flows' ? 'flows' : 'nbar'); + if (str_contains($scenario, 'fragment')) { expect($redirect['header'])->toBe('false'); } else { expect($redirect)->not->toHaveKey('header'); } + if (in_array($scenario, ['nav-redirect-header', 'nav-redirect-fragment-header'], true)) { expect($redirect['headercontent'])->toBe('true'); } - } elseif ($scenario === 'csrf') { expect($stdout)->toBe(''); expect($result['status'])->toBe(403); @@ -30,7 +33,6 @@ expect($stdout)->not->toContain('injected', 'not-an-ip', 'nested', 'toBeTrue(); } - } finally { foreach (glob($directory . '/*') as $file) { unlink($file); diff --git a/tests/Unit/NbarReviewRegressionTest.php b/tests/Unit/NbarReviewRegressionTest.php index 500edbd8..85e0fa01 100644 --- a/tests/Unit/NbarReviewRegressionTest.php +++ b/tests/Unit/NbarReviewRegressionTest.php @@ -1,5 +1,5 @@ toBeFalse(); }); it('does not merge matching connection tuples from distinct exporters', function () { - $row = ['app_id' => 'a1', 'src_hex' => 'C0000201', 'dst_hex' => 'C6336401', 'sport' => 10, 'dport' => 443, 'protocol' => 6, 'router' => '192.0.2.1']; - $names = [1 => ['192.0.2.1:a1' => ['name_id' => 1], '192.0.2.2:a1' => ['name_id' => 2]]]; - $key = fv_nbar_connection_key($row, $names, 1); + $row = ['app_id' => 'a1', 'src_hex' => 'C0000201', 'dst_hex' => 'C6336401', 'sport' => 10, 'dport' => 443, 'protocol' => 6, 'router' => '192.0.2.1']; + $names = [1 => ['192.0.2.1:a1' => ['name_id' => 1], '192.0.2.2:a1' => ['name_id' => 2]]]; + $key = fv_nbar_connection_key($row, $names, 1); $row['router'] = '192.0.2.2'; expect($key)->not->toBe(fv_nbar_connection_key($row, $names, 1)); }); diff --git a/tests/Unit/NbarSchemaTest.php b/tests/Unit/NbarSchemaTest.php index ab82af31..e3a0c1fc 100644 --- a/tests/Unit/NbarSchemaTest.php +++ b/tests/Unit/NbarSchemaTest.php @@ -1,5 +1,5 @@ toBe('InnoDB'); $columns = array_column($definition['columns'], null, 'name'); + foreach (explode('`,`', $definition['primary']) as $key) { expect($columns)->toHaveKey($key); expect($columns[$key]['NULL'])->toBeFalse(); } + foreach ($definition['keys'] as $index) { foreach ($index['columns'] as $key) { expect($columns)->toHaveKey($key); @@ -27,7 +29,7 @@ plugin_test_queue_db_result('db_fetch_assoc', []); plugin_test_queue_db_result('db_fetch_cell', 'plugin_flowview_nbar_' . $suffix); expect(fv_nbar_schema_table($suffix))->toBeTrue(); - $calls = $GLOBALS['__test_db_calls']; + $calls = $GLOBALS['__test_db_calls']; $create = array_values(array_filter($calls, fn ($call) => str_starts_with($call['sql'], 'CREATE TABLE'))); expect($create)->toHaveCount(1); expect($create[0]['conn'])->toBe('nbar-schema-test'); @@ -55,7 +57,6 @@ expect($sql)->not->toContain('CREATE TABLE', 'REPLACE INTO plugin_db_changes'); }); - it('installs the complete schema and upgrades missing columns on the flow connection', function () { foreach (['names', 'data', 'hours', 'apps', 'pairs', 'connections', 'daily_apps', 'daily_pairs', 'daily_connections', 'days'] as $suffix) { plugin_test_queue_db_result('db_fetch_cell', 'plugin_flowview_nbar_' . $suffix); @@ -65,6 +66,7 @@ expect(fv_nbar_schema_install())->toBeTrue(); $alter = array_values(array_filter($GLOBALS['__test_db_calls'], fn ($call) => $call['fn'] === 'db_add_column')); expect($alter)->toHaveCount(15); + foreach ($alter as $call) { expect($call['conn'])->toBe('nbar-schema-test'); } diff --git a/tests/Unit/NbarSessionCacheTest.php b/tests/Unit/NbarSessionCacheTest.php index db8ab780..5d9abf2c 100644 --- a/tests/Unit/NbarSessionCacheTest.php +++ b/tests/Unit/NbarSessionCacheTest.php @@ -1,5 +1,5 @@ oldSessionPath = session_save_path(); + $this->oldSessionPath = session_save_path(); $this->oldSessionCookies = ini_get('session.use_cookies'); $this->oldSessionLimiter = session_cache_limiter(); session_save_path(sys_get_temp_dir()); @@ -51,6 +51,7 @@ it('expires stale cache entries and evicts the oldest compact result', function () { $_SESSION['fv_nbar_query_cache'] = ['expired' => ['until' => time() - 1, 'rows' => []]]; + foreach (range(1, 12) as $i) { $_SESSION['fv_nbar_query_cache']['item' . $i] = ['until' => time() + 100, 'rows' => []]; } @@ -68,7 +69,6 @@ expect($_SESSION['fv_nbar_query_cache'])->toBe([]); }); - it('reuses connection pages only for the same account and page', function () { $_SESSION['sess_user_id'] = 1; expect(fv_nbar_connections_cached([1], 100, 100, '1=1', [], 1))->toBe([]); @@ -77,7 +77,7 @@ expect($GLOBALS['__test_db_calls'])->toHaveCount($calls); fv_nbar_connections_cached([1], 100, 100, '1=1', [], 2); expect(count($GLOBALS['__test_db_calls']))->toBeGreaterThan($calls); - $calls = count($GLOBALS['__test_db_calls']); + $calls = count($GLOBALS['__test_db_calls']); $_SESSION['sess_user_id'] = 2; fv_nbar_connections_cached([1], 100, 100, '1=1', [], 1); expect(count($GLOBALS['__test_db_calls']))->toBeGreaterThan($calls); @@ -86,6 +86,7 @@ it('expires old connection pages and retains at most three requested pages', function () { $_SESSION['fv_nbar_connection_pages'] = ['expired' => ['until' => time() - 1, 'rows' => []]]; + foreach (range(1, 3) as $i) { $_SESSION['fv_nbar_connection_pages']['page' . $i] = ['until' => time() + 100, 'rows' => []]; } diff --git a/tests/Unit/NbarTemplateCacheTest.php b/tests/Unit/NbarTemplateCacheTest.php index c4f252db..69c6daf9 100644 --- a/tests/Unit/NbarTemplateCacheTest.php +++ b/tests/Unit/NbarTemplateCacheTest.php @@ -1,28 +1,31 @@ store('192.0.2.1', (string) $i, 256, $template, 100))->toBeTrue(); } expect($cache->get('192.0.2.1', '0', 256, 101))->toBe($template); $cache->store('192.0.2.1', '64', 256, $template, 101); expect($cache->get('192.0.2.1', '1', 256, 101))->toBeNull()->and($cache->get('192.0.2.1', '0', 256, 101))->toBe($template); - for ($i = 65;$i < 500;$i++) { + + for ($i = 65; $i < 500; $i++) { $cache->store('192.0.2.1', (string) $i, 256, $template, 101); } expect($cache->usage('192.0.2.1')['exporter_templates'])->toBe(64); }); it('bounds global template count independently of per-exporter churn', function () { - $cache = new FlowviewNbarTemplateCache(); + $cache = new FlowviewNbarTemplateCache(); $template = [[[95, 4]], 4, false]; - for ($i = 0;$i < 300;$i++) { + + for ($i = 0; $i < 300; $i++) { $cache->store('192.0.2.' . ($i % 5), (string) $i, 256, $template, 100); } expect($cache->usage('192.0.2.0')['templates'])->toBe(256); @@ -34,7 +37,8 @@ $cache->store('192.0.2.1', 'a', 256, $large, 100); $cache->store('192.0.2.1', 'b', 256, $large, 100); expect($cache->get('192.0.2.1', 'a', 256, 101))->toBeNull(); - for ($i = 2;$i < 7;$i++) { + + for ($i = 2; $i < 7; $i++) { $cache->store('192.0.2.' . $i, 'a', 256, $large, 100); } expect($cache->usage('192.0.2.6')['fields'])->toBe(8192)->and($cache->usage('192.0.2.6')['exporter_fields'])->toBe(2048); @@ -42,7 +46,7 @@ expect($cache->get('192.0.2.6', 'a', 256, 102))->toBeNull(); }); it('expires inactive templates and refreshes activity on successful use', function () { - $cache = new FlowviewNbarTemplateCache(); + $cache = new FlowviewNbarTemplateCache(); $template = [[[95, 4]], 4, false]; $cache->store('192.0.2.1', 'a', 256, $template, 100); $cache->store('192.0.2.1', 'b', 256, $template, 100); diff --git a/tests/Unit/NbarWorkerDeadlineTest.php b/tests/Unit/NbarWorkerDeadlineTest.php index 63bfa072..efc48def 100644 --- a/tests/Unit/NbarWorkerDeadlineTest.php +++ b/tests/Unit/NbarWorkerDeadlineTest.php @@ -17,31 +17,39 @@ it('supervises a direct PHP child and releases registration after success or failure', function ($status, $invalidPhp) { global $config; $oldBase = $config['base_path']; - $root = sys_get_temp_dir() . "/flowview supervisor '" . bin2hex(random_bytes(8)); + $root = sys_get_temp_dir() . "/flowview supervisor '" . bin2hex(random_bytes(8)); mkdir($root . '/plugins/flowview/cli', 0700, true); $script = $root . '/plugins/flowview/cli/flowview_upgrade.php'; $marker = $root . '/args.json'; file_put_contents($script, 'toBe(['register_process_start', 'unregister_process']); + if ($invalidPhp) { expect(file_exists($marker))->toBeFalse(); } else { expect(json_decode(file_get_contents($marker), true))->toBe([$script, '--nbar-worker=build']); } + if ($invalidPhp || $status !== 0) { expect(implode(' ', $GLOBALS['__test_cacti_log']))->toContain('failed or exceeded'); } } finally { $config['base_path'] = $oldBase; + if (file_exists($marker)) { - unlink($marker); + unlink($marker); } - unlink($script); rmdir($root . '/plugins/flowview/cli'); rmdir($root . '/plugins/flowview'); rmdir($root . '/plugins'); rmdir($root); + unlink($script); + rmdir($root . '/plugins/flowview/cli'); + rmdir($root . '/plugins/flowview'); + rmdir($root . '/plugins'); + rmdir($root); } })->with([[0, false], [7, false], [0, true]]); it('does not start another child while maintenance is registered', function () { diff --git a/tests/fixtures/nbar-count-request.php b/tests/fixtures/nbar-count-request.php index b3eefc43..4b6cfa21 100644 --- a/tests/fixtures/nbar-count-request.php +++ b/tests/fixtures/nbar-count-request.php @@ -1,13 +1,14 @@ $key]; -$entry = ['expires' => time() + 3600, 'sql' => 'SELECT COUNT(*) total FROM fixture WHERE listener_id=?', 'args' => [3], 'ttl' => 600]; +$key = str_repeat('a', 64); +$_REQUEST = ['key' => $key]; +$entry = ['expires' => time() + 3600, 'sql' => 'SELECT COUNT(*) total FROM fixture WHERE listener_id=?', 'args' => [3], 'ttl' => 600]; $_SESSION['fv_nbar_counts'] = [$key => $entry]; + if ($scenario === 'malformed') { $_REQUEST['key'] = ['nested']; } elseif ($scenario === 'missing') { @@ -51,9 +54,11 @@ plugin_test_queue_db_result('db_fetch_cell', 1); plugin_test_queue_db_result('db_fetch_cell', '11.8.9-MariaDB'); plugin_test_queue_db_result('db_fetch_row_prepared', str_contains($scenario, 'failed') ? false : ['total' => 12345]); + if (str_starts_with($scenario, 'background')) { function fastcgi_finish_request(): bool { $GLOBALS['__test_response_finished'] = true; + return true; } } diff --git a/tests/fixtures/nbar-direct-request.php b/tests/fixtures/nbar-direct-request.php index 081619f8..5996c714 100644 --- a/tests/fixtures/nbar-direct-request.php +++ b/tests/fixtures/nbar-direct-request.php @@ -1,12 +1,13 @@ $_SESSION ?? [], 'calls' => $GLOBALS['__test_db_calls'] ?? [], 'coverage' => $coverage, 'status' => http_response_code()])); }); $files = ['guide' => 'nbar_setup_guide.php', 'report' => 'nbar_report.php']; + if (!isset($files[$scenario])) { exit(1); } diff --git a/tests/fixtures/nbar-dns-budget.php b/tests/fixtures/nbar-dns-budget.php index f76676e3..4730b348 100644 --- a/tests/fixtures/nbar-dns-budget.php +++ b/tests/fixtures/nbar-dns-budget.php @@ -1,13 +1,14 @@ $_SESSION ?? [], 'calls' => $GLOBALS['__test_db_calls'] ?? [], 'coverage' => $coverage, 'status' => http_response_code()])); }); -class Net_DNS2_Resolver -{ +class Net_DNS2_Resolver { public $timeout; public static int $queries = 0; public function __construct(array $options) { } public function query(string $ip, string $type): object { self::$queries++; + return (object) ['answer' => []]; } } @@ -42,4 +44,4 @@ public function query(string $ip, string $type): object { $GLOBALS['fv_nbar_preferences'] = ['dns' => '1']; fv_nbar_dns('192.0.2.249'); usleep(1100000); -echo json_encode(['name' => fv_nbar_dns('192.0.2.250'), 'queries' => Net_DNS2_Resolver::$queries]); +print json_encode(['name' => fv_nbar_dns('192.0.2.250'), 'queries' => Net_DNS2_Resolver::$queries]); diff --git a/tests/fixtures/nbar-report-request.php b/tests/fixtures/nbar-report-request.php index 34d9a781..3a5aa057 100644 --- a/tests/fixtures/nbar-report-request.php +++ b/tests/fixtures/nbar-report-request.php @@ -1,13 +1,14 @@ '; + print ''; } /** * Close the fixture report-box markup. @@ -58,7 +61,7 @@ function html_start_box(mixed ...$args): void { * @return void */ function html_end_box(mixed ...$args): void { - echo '
    '; + print ''; } /** * Record completion without loading Cacti's real page footer. @@ -68,13 +71,15 @@ function html_end_box(mixed ...$args): void { function bottom_footer(): void { $GLOBALS['__test_footer_rendered'] = true; } -$GLOBALS['__test_config']['flowview_nbar_schema_version'] = '2'; +$GLOBALS['__test_config']['flowview_nbar_schema_version'] = '2'; $GLOBALS['__test_user_settings']['flowview_nbar_preferences'] = json_encode(['prefixes' => ['192.0.2.0/24'], 'listeners' => [], 'dns' => '0']); -$graph_timespans = [GT_LAST_HALF_HOUR => 'Last Half Hour', GT_LAST_HOUR => 'Last Hour', GT_LAST_DAY => 'Last Day', GT_LAST_YEAR => 'Last Year']; -$_REQUEST = ['action' => 'view', 'report' => 'nbar', 'mode' => 'apps', 'listener' => '1']; -$_SERVER['REQUEST_METHOD'] = 'GET'; +$graph_timespans = [GT_LAST_HALF_HOUR => 'Last Half Hour', GT_LAST_HOUR => 'Last Hour', GT_LAST_DAY => 'Last Day', GT_LAST_YEAR => 'Last Year']; +$_REQUEST = ['action' => 'view', 'report' => 'nbar', 'mode' => 'apps', 'listener' => '1']; +$_SERVER['REQUEST_METHOD'] = 'GET'; + if (str_starts_with($scenario, 'nav-redirect')) { $_REQUEST = in_array($scenario, ['nav-redirect-header', 'nav-redirect-fragment-header'], true) ? ['headercontent' => 'true'] : []; + if (str_contains($scenario, 'fragment')) { $_REQUEST += ['action' => 'view', 'header' => 'false']; } @@ -120,15 +125,17 @@ function bottom_footer(): void { plugin_test_queue_db_result('db_execute', false); } elseif ($scenario === 'upgrade') { $GLOBALS['__test_config']['flowview_nbar_schema_version'] = '0'; - $_SERVER['PHP_SELF'] = 'fixture.php'; + $_SERVER['PHP_SELF'] = 'fixture.php'; } elseif ($scenario === 'more-pages') { $_REQUEST['mode'] = 'connections'; - $from = (int) floor(time() / 3600) * 3600 - 3600; + $from = (int) floor(time() / 3600) * 3600 - 3600; $_REQUEST += ['predefined_timespan' => '0', 'date1' => date('Y-m-d H:i', $from), 'date2' => date('Y-m-d H:i', $from + 1200), 'lock_range' => '1']; + foreach (range(1, 4) as $unused) { plugin_test_queue_db_result('db_fetch_assoc_prepared', []); } $rows = []; + foreach (range(1, 101) as $i) { $rows[] = ['app_id' => '0d000001', 'src_hex' => bin2hex(inet_pton('192.0.2.' . $i)), 'dst_hex' => bin2hex(inet_pton('198.51.100.20')), 'sport' => $i, 'dport' => 443, 'protocol' => 6, 'bytes' => '10', 'packets' => '1', 'records' => '1', 'first_seen' => $from, 'last_seen' => $from + 60]; } @@ -139,6 +146,7 @@ function bottom_footer(): void { $_REQUEST += ['prefs_apply' => '1', 'local_prefixes' => '']; $GLOBALS['__test_csrf_valid'] = false; } + foreach (range(1, 3) as $unused) { plugin_test_queue_db_result('db_fetch_assoc', []); } diff --git a/tests/helpers/request.php b/tests/helpers/request.php index d42ad3ca..de8fa898 100644 --- a/tests/helpers/request.php +++ b/tests/helpers/request.php @@ -1,14 +1,16 @@ toBe(0); expect($stderr)->toBe(''); - $result = json_decode(file_get_contents($directory . '/result.json'), true, 512, JSON_THROW_ON_ERROR); + $result = json_decode(file_get_contents($directory . '/result.json'), true, 512, JSON_THROW_ON_ERROR); $coverage = \PHPUnit\Runner\CodeCoverage::instance(); + if ($result['coverage'] && $coverage->isActive()) { $coverage->codeCoverage()->append(\SebastianBergmann\CodeCoverage\Data\RawCodeCoverageData::fromXdebugWithoutPathCoverage($result['coverage'])); } + return [$stdout, $result]; } From 62dcd256f3095621f60bc38f282105bcd81b2f31 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Mon, 19 Oct 2026 12:41:03 -0400 Subject: [PATCH 19/27] fix: guard the background database upgrade with a process registration plugin_flowview_check_upgrade() runs on every page load while an upgrade is pending, so each request could spawn another background upgrade. Launch it only when register_process_start() succeeds (four hour timeout). cli/flowview_upgrade.php releases that lock, and its own run lock, however it ends (early exit, success, or fatal error via a shutdown function); its own registration timeout drops from 24 hours to 4 hours to match. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/flowview_upgrade.php | 13 ++++++++++--- setup.php | 6 +++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/cli/flowview_upgrade.php b/cli/flowview_upgrade.php index 0045a121..1c969820 100644 --- a/cli/flowview_upgrade.php +++ b/cli/flowview_upgrade.php @@ -105,22 +105,29 @@ /* do a version check */ if ($forcever == '' && $old_version == $current_version && (int) read_config_option('flowview_nbar_schema_version') >= 2) { cacti_log('Your Flowview is already up to date (v' . $current_version . ' vs v' . $old_version . ') not upgrading. Use --forcever to override', true, 'FLOWVIEW'); + + unregister_process('flowview', 'upgrade_launch', 0); + exit(0); } -if (!register_process_start('flowview', 'upgrade', 1, 86400)) { +if (!register_process_start('flowview', 'upgrade', 1, 14400)) { if (empty($forcever)) { print 'WARNING: Running process detected. To override use --forcever' . PHP_EOL; exit(1); } } +// release this run and the web launcher's lock however the script ends, including a fatal error +register_shutdown_function(static function (): void { + unregister_process('flowview', 'upgrade', 1); + unregister_process('flowview', 'upgrade_launch', 0); +}); + cacti_log('Upgrading from v' . $old_version . ' to ' . $current_version, true, 'FLOWVIEW'); flowview_upgrade($current_version, $old_version); -unregister_process('flowview', 'upgrade', 1); - exit(0); /** diff --git a/setup.php b/setup.php index 6c18e4fc..ef321e4e 100644 --- a/setup.php +++ b/setup.php @@ -166,7 +166,11 @@ function plugin_flowview_check_upgrade(bool $force = false): void { 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/cli/flowview_upgrade.php'); + /* This check runs on every page load. Launch the upgrade once; cli/flowview_upgrade.php releases the + * lock when it finishes, and the four hour timeout recovers it if that process dies. */ + if (register_process_start('flowview', 'upgrade_launch', 0, 14400)) { + exec_background($php_binary, '-q ' . $config['base_path'] . '/plugins/flowview/cli/flowview_upgrade.php'); + } raise_message('flowview_upgrade', __('Please be advised the Flowview plugins Tables are being upgraded in the background. This may take some time. Check the Cacti log for more information'), MESSAGE_LEVEL_INFO); From 22a4fed274a3c59360a193caeadd20a6998bdde4 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Mon, 19 Oct 2026 12:41:04 -0400 Subject: [PATCH 20/27] refactor: use cacti_count()/cacti_sizeof(); fix PHPStan level 8 findings in NBAR code - Replace count()/sizeof() with cacti_count()/cacti_sizeof() in production code (Net/ excluded); add a cacti_count stub to the unit bootstrap. - Narrow-width fv_nbar_int() for NetFlow header fields; typed/guarded results for unpack, preg_split, inet_pton/inet_ntop, hex2bin, json_encode, parse_url. - Declare variables inherited by the included report fragments, initialise by-reference arguments, and pass correctly typed html_start_box() arguments. - Remove an always-true condition in fv_section_params(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- flowview_sections.php | 12 ++++------ includes/functions.php | 2 +- includes/nbar_collector.php | 44 ++++++++++++++++++++++------------- includes/nbar_common.php | 11 +++++++-- includes/nbar_connections.php | 6 ++--- includes/nbar_database.php | 1 + includes/nbar_preferences.php | 6 ++--- includes/nbar_timespan.php | 2 +- nbar_conversations.php | 16 +++++++++++++ nbar_report.php | 16 +++++++++---- nbar_setup_guide.php | 1 + tests/bootstrap-unit.php | 6 +++++ 12 files changed, 86 insertions(+), 37 deletions(-) diff --git a/flowview_sections.php b/flowview_sections.php index d21afa0f..7f05718e 100644 --- a/flowview_sections.php +++ b/flowview_sections.php @@ -64,14 +64,12 @@ function fv_section_params(string $section): array { } $p['predefined_timespan'] = 1; // Re-enter reports with a fresh half-hour window. - if ((int) ($p['predefined_timespan'] ?? 0) > 0) { - unset($p['date1'],$p['date2']); - $c = json_decode($p['context'] ?? '', true); + unset($p['date1'],$p['date2']); + $c = json_decode($p['context'] ?? '', true); - if (is_array($c)) { - unset($c['date1'],$c['date2']); - $p['context'] = json_encode($c); - } + if (is_array($c)) { + unset($c['date1'],$c['date2']); + $p['context'] = json_encode($c); } $p['action'] = 'view'; $p['section'] = $section; diff --git a/includes/functions.php b/includes/functions.php index bbe9f3f0..2a5d73e0 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -2832,7 +2832,7 @@ function run_flow_query($session, $query_id, $start, $end) { $data['printed'] = $nreport; $data['statistics'] = 0; } - } elseif (!sizeof($data)) { + } elseif (!cacti_sizeof($data)) { return false; } diff --git a/includes/nbar_collector.php b/includes/nbar_collector.php index 1f4c2d2d..6121289e 100644 --- a/includes/nbar_collector.php +++ b/includes/nbar_collector.php @@ -51,7 +51,7 @@ public function remove(string $router, string $source, int $id): void { public function store(string $router, string $source, int $id, array $template, int $now): bool { $this->prune($now); $this->remove($router, $source, $id); - $fields = count($template[0]); + $fields = cacti_count($template[0]); if ($fields > 2048) { return false; @@ -97,10 +97,10 @@ public function get(string $router, string $source, int $id, int $now): ?array { } public function usage(string $router): array { - $usage = ['templates' => count($this->entries), 'fields' => 0, 'exporter_templates' => 0, 'exporter_fields' => 0]; + $usage = ['templates' => cacti_count($this->entries), 'fields' => 0, 'exporter_templates' => 0, 'exporter_fields' => 0]; foreach ($this->entries as $entry) { - $fields = count($entry['template'][0]); + $fields = cacti_count($entry['template'][0]); $usage['fields'] += $fields; if ($entry['router'] === $router) { @@ -126,9 +126,9 @@ function fv_nbar_uint(string $s): int|string { } $n = 0; - foreach (unpack('C*', $s) as $v) { + foreach (unpack('C*', $s) ?: [] as $v) { if (is_int($n) && $n <= intdiv(PHP_INT_MAX - $v, 256)) { - $n = $n * 256 + $v; + $n = (int) ($n * 256 + $v); continue; } @@ -146,6 +146,18 @@ function fv_nbar_uint(string $s): int|string { return $n; } + +/** + * Decode a network-order integer of at most four bytes, which always fits a native int. + * + * @param string $s Network-order bytes. + * + * @return int + */ +function fv_nbar_int(string $s): int { + return (int) fv_nbar_uint($s); +} + /** * Decode template-defined NetFlow v9 application records and names. * @@ -158,22 +170,22 @@ function fv_nbar_packet(string $p, string $router): void { global $listener_id; static $templates = null; - if (strlen($p) < 20 || fv_nbar_uint(substr($p, 0, 2)) !== 9) { + if (strlen($p) < 20 || fv_nbar_int(substr($p, 0, 2)) !== 9) { return; } $templates ??= new FlowviewNbarTemplateCache(); $now = time(); $templates->prune($now); $source = bin2hex(substr($p, 16, 4)); - $unix = fv_nbar_uint(substr($p, 8, 4)); - $uptime = fv_nbar_uint(substr($p, 4, 4)); + $unix = fv_nbar_int(substr($p, 8, 4)); + $uptime = fv_nbar_int(substr($p, 4, 4)); $rows = []; $args = []; $hours = []; for ($o = 20; $o + 4 <= strlen($p);) { - $id = fv_nbar_uint(substr($p, $o, 2)); - $len = fv_nbar_uint(substr($p, $o + 2, 2)); + $id = fv_nbar_int(substr($p, $o, 2)); + $len = fv_nbar_int(substr($p, $o + 2, 2)); if ($len < 4 || $o + $len > strlen($p)) { break; @@ -183,18 +195,18 @@ function fv_nbar_packet(string $p, string $router): void { if ($id === 0 || $id === 1) { while ($q + ($id === 0 ? 4 : 6) <= $end) { - $tid = fv_nbar_uint(substr($p, $q, 2)); + $tid = fv_nbar_int(substr($p, $q, 2)); if ($tid < 256) { break; } if ($id === 0) { - $count = fv_nbar_uint(substr($p, $q + 2, 2)); + $count = fv_nbar_int(substr($p, $q + 2, 2)); $size = $count * 4; $q += 4; } else { - $size = fv_nbar_uint(substr($p, $q + 2, 2)) + fv_nbar_uint(substr($p, $q + 4, 2)); + $size = fv_nbar_int(substr($p, $q + 2, 2)) + fv_nbar_int(substr($p, $q + 4, 2)); $q += 6; } @@ -208,8 +220,8 @@ function fv_nbar_packet(string $p, string $router): void { $valid = true; for ($z = 0; $z < $size; $z += 4) { - $f = fv_nbar_uint(substr($p, $q + $z, 2)); - $l = fv_nbar_uint(substr($p, $q + $z + 2, 2)); + $f = fv_nbar_int(substr($p, $q + $z, 2)); + $l = fv_nbar_int(substr($p, $q + $z + 2, 2)); $width = [1 => 8, 2 => 8, 4 => 1, 7 => 2, 11 => 2, 21 => 4][$f] ?? null; if ($l === 0 || ($id === 0 && $width !== null && $l > $width)) { @@ -262,7 +274,7 @@ function fv_nbar_packet(string $p, string $router): void { $ts = $unix; if (isset($data[21])) { - $age = ($uptime - fv_nbar_uint($data[21]) + 4294967296) % 4294967296; + $age = ($uptime - fv_nbar_int($data[21]) + 4294967296) % 4294967296; $ts = $unix - (int) floor($age / 1000); } diff --git a/includes/nbar_common.php b/includes/nbar_common.php index 4e43bf1f..cc293802 100644 --- a/includes/nbar_common.php +++ b/includes/nbar_common.php @@ -142,7 +142,7 @@ function fv_nbar_supervise_maintenance(): void { try { // Invoke PHP directly; older Cacti timeout helpers accept strings only. - $command = [read_config_option('path_php_binary'), '-q', $config['base_path'] . '/plugins/flowview/cli/flowview_upgrade.php', '--nbar-worker=build']; + $command = [(string) read_config_option('path_php_binary'), '-q', $config['base_path'] . '/plugins/flowview/cli/flowview_upgrade.php', '--nbar-worker=build']; $status = fv_nbar_run_child($command, 90); if ($status !== 0) { @@ -153,7 +153,14 @@ function fv_nbar_supervise_maintenance(): void { } } -/** Supervise a direct PHP child independently of Cacti's versioned command API. */ +/** + * Supervise a direct PHP child independently of Cacti's versioned command API. + * + * @param list $command Executable followed by its arguments. + * @param int $seconds Hard process deadline in seconds. + * + * @return int + */ function fv_nbar_run_child(array $command, int $seconds): int { $pipes = []; $process = false; diff --git a/includes/nbar_connections.php b/includes/nbar_connections.php index bb85a7b4..d641717e 100644 --- a/includes/nbar_connections.php +++ b/includes/nbar_connections.php @@ -69,7 +69,7 @@ function fv_nbar_decimal_compare(int|string $a, int|string $b): int { * @return string */ function fv_nbar_connection_key(array $row, array $names, int $listener): string { - return json_encode([(string) $row['app_id'], (int) ($names[$listener][($row['router'] ?? '') . ':' . $row['app_id']]['name_id'] ?? 0), $row['src_hex'], $row['dst_hex'], (int) $row['sport'], (int) $row['dport'], (int) $row['protocol'], (string) ($row['router'] ?? '')]); + return json_encode([(string) $row['app_id'], (int) ($names[$listener][($row['router'] ?? '') . ':' . $row['app_id']]['name_id'] ?? 0), $row['src_hex'], $row['dst_hex'], (int) $row['sport'], (int) $row['dport'], (int) $row['protocol'], (string) ($row['router'] ?? '')], JSON_THROW_ON_ERROR); } /** * Order connection totals by exact byte count and deterministic tuple fields. @@ -349,8 +349,8 @@ function fv_nbar_connections_direct(array $listeners, int $start, int $end, stri $result = array_slice($ranked, $offset, $size); foreach ($result as &$row) { - $row['src'] = inet_ntop(hex2bin($row['src_hex'])); - $row['dst'] = inet_ntop(hex2bin($row['dst_hex'])); + $row['src'] = inet_ntop((string) hex2bin($row['src_hex'])); + $row['dst'] = inet_ntop((string) hex2bin($row['dst_hex'])); unset($row['_key'],$row['src_hex'],$row['dst_hex']); } unset($row); flowview_db_execute('COMMIT'); diff --git a/includes/nbar_database.php b/includes/nbar_database.php index 0ce1bcd2..7cf4e0da 100644 --- a/includes/nbar_database.php +++ b/includes/nbar_database.php @@ -115,6 +115,7 @@ function fv_nbar_schema_definition(string $suffix): array { $primary[] = 'router'; $columns[] = 'router'; } + /** @var array> $fields */ $fields = array_merge($fields, $extra); $definition = ['columns' => [], 'primary' => implode('`,`', $primary), 'keys' => $keys, 'type' => 'InnoDB']; diff --git a/includes/nbar_preferences.php b/includes/nbar_preferences.php index b8dc7357..e9e459c5 100644 --- a/includes/nbar_preferences.php +++ b/includes/nbar_preferences.php @@ -34,13 +34,13 @@ function fv_nbar_cidrs(string $text): array|false { $out = []; - foreach (preg_split('/[\s,]+/', trim($text), -1, PREG_SPLIT_NO_EMPTY) as $cidr) { + foreach (preg_split('/[\s,]+/', trim($text), -1, PREG_SPLIT_NO_EMPTY) ?: [] as $cidr) { $parts = explode('/', $cidr); if (cacti_sizeof($parts) !== 2 || !ctype_digit($parts[1]) || !filter_var($parts[0], FILTER_VALIDATE_IP)) { return false; } - $packed = inet_pton($parts[0]); + $packed = (string) inet_pton($parts[0]); $bits = (int) $parts[1]; if ($bits < 0 || $bits > strlen($packed) * 8) { @@ -128,7 +128,7 @@ function fv_nbar_local_sql(string $f): string { foreach ($GLOBALS['fv_nbar_preferences']['prefixes'] as $cidr) { [$ip,$bits] = explode('/', $cidr); - $packed = inet_pton($ip); + $packed = (string) inet_pton($ip); $bits = (int) $bits; $whole = intdiv($bits, 8); $rest = $bits % 8; diff --git a/includes/nbar_timespan.php b/includes/nbar_timespan.php index 1d41b552..fc44a45b 100644 --- a/includes/nbar_timespan.php +++ b/includes/nbar_timespan.php @@ -39,7 +39,7 @@ function fv_nbar_timespan_options(array $presets, int $now, int $days, int|strin foreach ($presets as $id => $label) { $span = []; - get_timespan($span, $now, $id, $weekday); + get_timespan($span, $now, $id, (int) $weekday); $from = strtotime($span['current_value_date1']); $to = strtotime($span['current_value_date2']); diff --git a/nbar_conversations.php b/nbar_conversations.php index 16357e53..a641eb0e 100644 --- a/nbar_conversations.php +++ b/nbar_conversations.php @@ -24,6 +24,22 @@ */ // Conversation direction uses configured site networks and selected listeners. + +/** + * Report state supplied by nbar_report.php, which includes this file. + * + * @var array $nbar_preferences + * @var string $listener_scope + * @var int $lid + * @var int $start + * @var int $end + * @var int $seconds + * @var string $family + * @var string $aid + * @var string $host + * @var string $base + * @var array $combined_lids + */ if (empty($nbar_preferences['prefixes'])) { print '

    ' . html_escape(__('Configure your site networks in Application settings to label internal and external devices. Top applications and Connections work directly with exported IPv4 and IPv6 addresses.', 'flowview')) . '

    '; diff --git a/nbar_report.php b/nbar_report.php index a9781e02..2f8f5b23 100644 --- a/nbar_report.php +++ b/nbar_report.php @@ -42,6 +42,13 @@ // arrays.php populates report variables in this scope, so it is included on each request. include(__DIR__ . '/includes/arrays.php'); +/** + * Report arrays supplied by Cacti core and includes/arrays.php. + * + * @var array $graph_timespans + * @var array> $stat_report_array + * @var array> $print_report_array + */ if ((int) read_config_option('flowview_nbar_schema_version') < 2) { plugin_flowview_check_upgrade(); print '

    ' . html_escape(__('NBAR history is being upgraded. Refresh this report shortly.', 'flowview')) . '

    '; @@ -147,6 +154,7 @@ $start = $end - $nbar_retention_days * 86400; $custom = false; } elseif ($timespan > 0) { + $nbar_span = []; 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']); @@ -213,7 +221,7 @@ if (!isset(($context['report'][0] === 's' ? $stat_report_array : $print_report_array)[$report_key])) { $context['report'] = 's10'; } -$context_json = json_encode($context); +$context_json = json_encode($context, JSON_THROW_ON_ERROR); $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'; @@ -223,11 +231,11 @@ $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); +parse_str((string) 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', ''); +html_start_box(__('Application Report: Cisco Apps Recognition (NBAR)', 'flowview'), '100%', false, 3, 'center', ''); print ''; fv_nbar_feed_status($devices, array_map('intval', $lid === 0 ? $combined_lids : [$lid])); print ''; @@ -240,7 +248,7 @@ foreach (['internal', 'external'] as $f) { if ($mode === 'connections' && filter_var($nbar_request[$f] ?? '', FILTER_VALIDATE_IP)) { - print ''; + print ''; } } print '
    ' . html_escape(__('Router / capture', 'flowview')) . ' '> - > - + + > + + '> - > - + + > + +
    @@ -1037,20 +1041,28 @@ function flowview_display_filter(): void { - > - + + > + + - > - + + > + + - > - + + > + + - > - + + > + +