Files
cacti/poller_realtime.php
TheWitnessandTheWitness 3ab4c69fb7 PHPStan Level 8 - Phase 1 Conversion (#8135)
* PHPStan Level 8 - Phase 1 Conversion

Reduces the real (no-baseline) PHPStan level 8 error count from ~2,724 to 425
and regenerates phpstan-baseline.neon accordingly (8,941 -> 1,921 lines).

Core type-safety changes
- lib/database.php: narrow db_fetch_row()/db_fetch_row_prepared() to return
  array (was bool|array) and db_fetch_assoc()/db_fetch_assoc_prepared() to
  return array (was mixed); the _prepared workers coerce the connection-failure
  path to an empty array so the declared type is honest. empty()/cacti_sizeof()
  checks are unaffected.
- lib/database.php: narrow $db_conn guards from is_object() to instanceof PDO in
  the transaction/quote/insert-id helpers so the PDO calls resolve.
- lib/mib_cache.php: the fluent MibCache methods mib()/object()/table()/row()
  now return self instead of a bare object so chained calls type-check.

Type-safety casts and guards (mechanical, behaviour-preserving)
- Cast string|false/|null builtin results feeding string parameters
  (json_decode, base64_encode, trim, htmle, preg_replace, explode, etc.).
- Cast int|string / int|false values feeding int id/pid parameters.
- Guard resource|false fopen()/popen() handles before fwrite/fclose/fread, and
  wrap foreach over array|false/list|false results with `?: []`.

Genuine bugs found and fixed
- cli/add_tree.php / lib/api_tree.php: site tree nodes could not be created via
  the CLI - the --site-id option was never parsed, $nodeTypes had no 'site'
  entry (passing null to api_tree_item_save(int $type) -> TypeError), and
  api_tree_item_save() never persisted site_id. All three fixed.
- tree.php: leaves_exist() is typed : int but returned db_fetch_assoc_prepared()
  (an array of rows) for a SELECT COUNT(*); now uses db_fetch_cell_prepared()
  and returns the scalar count.
- lib/mib_cache.php: $entries_per_object was a float (division / ceil) used as
  an array key (PHP 8.1 implicit float-to-int key deprecation); now cast to int.

Config
- .phpstan.neon: add notIdentical.alwaysTrue and booleanAnd.alwaysFalse to the
  existing always-true/false ignore family (same "PHPStan gets confused"
  dynamic-constant cause already covered by identical.alwaysTrue,
  notEqual.alwaysTrue, booleanAnd.leftAlwaysTrue, etc.).
- phpstan-baseline.neon: regenerated; the remaining 425 entries are the
  untouched long tail for Phase 2.

* Remove redundant @phpstan-ignore comments

Follow-up cleanup for the level 8 Phase 1 work. Removes 14 inline
@phpstan-ignore comments that no longer suppress any reported error:

- cli/repair_database.php: `@phpstan-ignore booleanAnd.alwaysFalse` is now
  covered by the global booleanAnd.alwaysFalse ignore added to .phpstan.neon.
- 13 others (host.php, include/global.php, include/session.php,
  lib/api_automation.php x2, lib/functions.php x2, lib/html_utility.php x2,
  lib/installer.php, lib/package.php, lib/reports.php, lib/rrd.php) no longer
  match any reported error after the type-safety fixes (e.g. the
  db_fetch_row_prepared() return-type narrowing removed the offset-assign
  errors the api_automation ignores were suppressing).

Verified with reportUnmatchedIgnoredErrors enabled: 0 unmatched ignores
remain and the full level 8 run still reports 0 errors.

* ci: satisfy validator ratchet and refresh security sink baselines

- lib/api_tree.php: give the new tree-item site_id a real numeric
  constraint ('^[-0-9]+$', matching html_reports.php) instead of an
  empty regex, so the validator constraint ratchet passes without
  bumping its baseline.
- tests/security/baselines: refresh the three sink/hotspot signatures
  whose code text changed from the (string) casts added in this branch
  (lib/poller.php setsid, cli/rrdresize.php json_encode, lib/import.php
  simplexml_load_string).

* ci(js): allow DOMPurify 3.4.16 and update sync_js pin test

The dependabot bump (#8134) moved package.json/package-lock.json to
DOMPurify 3.4.16 on develop but left tests/js/sync_js.test.mjs pinned to
3.4.15, so the PR merge commit fails the 'DOMPurify is pinned' contract.
Bring package.json/package-lock.json to 3.4.16 (matching develop) and
update the pin test to expect 3.4.16.

* test: update sql.php null-handling assertion for the (string) cast

This branch changed scripts/sql.php from trim(\ ?? '') to
trim((string) \) (the phpstan-safe form that coerces both null and
false from shell_exec()). Update SqlScriptsTest's null-return assertion
to match the new cast instead of the removed '?? ''.

* fix: address Copilot review - ownership IDs, fopen guards, mib_cache bound

- chown()/chgrp(): pass integer UID/GID from fileowner()/filegroup()
  (guarding the false lookup) instead of string-casting them, which made
  them resolve account/group names like '1001' and fail. Covers
  lib/api_device.php, lib/boost.php and poller_maintenance.php (x2).
- fopen() failure now short-circuits instead of reporting success:
  lib/package.php returns false, lib/clog_webapi.php and utilities.php
  render an error, and snmpagent_mibcachechild.php skips the cache write
  and sentinel unlink so a concurrent writer's lock is preserved.
- lib/mib_cache.php: keep the float quotient as the loop bound and cast
  only the array offset, so a non-even entries/columns split no longer
  truncates the loop and drops a cached row.

* revert: keep db_fetch_* returning false on error; baseline Level 8 instead

Per review, restoring the intentional fail-safe: db_fetch_row[_prepared]()
return bool|array and db_fetch_assoc[_prepared]() return mixed again, so a
genuine SQL error (not just a missing connection) is signalled as false and
the === false checks in cdef_is_in_use(), boost_process_* and aggregate.php
work as before. Dropped the is_array() ? : [] coercion.

Level 8 stays green by regenerating phpstan-baseline.neon to capture the
re-introduced bool|array/mixed call-site errors (the ~1,850 the narrowing
had eliminated). Tightening those callers to drop them from the baseline is
Phase 2 work.

---------

Co-authored-by: TheWitness <copilot@users.noreply.github.com>
2026-10-02 10:08:56 -04:00

329 lines
10 KiB
PHP
Executable File

#!/usr/bin/env php
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU General Public License for more details. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
| This code is designed, written, and maintained by the Cacti Group. See |
| about.php and/or the AUTHORS file for specific developer information. |
+-------------------------------------------------------------------------+
| http://www.cacti.net/ |
+-------------------------------------------------------------------------+
*/
require(__DIR__ . '/include/cli_check.php');
require_once(CACTI_PATH_LIBRARY . '/poller.php');
require_once(CACTI_PATH_LIBRARY . '/data_query.php');
require_once(CACTI_PATH_LIBRARY . '/rrd.php');
// force Cacti to store realtime data locally
$config['force_storage_location_local'] = true;
// initialize some additional variables
$force = false;
$debug = false;
$graph_id = false;
$interval = false;
$poller_id = '';
// process calling arguments
$parms = $_SERVER['argv'];
array_shift($parms);
if (cacti_sizeof($parms)) {
foreach ($parms as $parameter) {
if (str_contains($parameter, '=')) {
[$arg, $value] = explode('=', $parameter, 2);
} else {
$arg = $parameter;
$value = '';
}
switch ($arg) {
case '-d':
case '--debug':
$debug = true;
break;
case '--force':
$force = true;
break;
case '--graph':
$graph_id = intval($value);
break;
case '--interval':
$interval = intval($value);
break;
case '--poller_id':
$poller_id = (string) $value;
break;
case '--version':
case '-V':
case '-v':
display_version();
exit;
case '--help':
case '-H':
case '-h':
display_help();
exit;
default:
print "ERROR: Invalid Argument: ($arg)\n\n";
display_help();
exit(1);
}
}
}
if ($graph_id === false || $graph_id < 0) {
print "ERROR: No --graph=ID specified\n\n";
display_help();
exit(1);
}
if ($interval === false || $interval < 0) {
print "ERROR: No --interval=SEC specified\n\n";
display_help();
exit(1);
}
if (!preg_match('/^(?:[0-9]+|[A-Fa-f0-9]{64})$/', $poller_id)) {
print "ERROR: No valid --poller_id=ID specified\n\n";
display_help();
exit(1);
}
// record the start time
$poller_start = microtime(true);
// get number of polling items from the database
$poller_interval = 1;
// retrieve the last time the poller ran
$poller_lastrun = read_config_option('poller_lastrun');
// get the current cron interval from the database
$cron_interval = read_config_option('cron_interval');
if ($cron_interval != 60) {
$cron_interval = 300;
}
// assume a scheduled task of either 60 or 300 seconds
define('MAX_POLLER_RUNTIME', 298);
// let PHP only run 1 second longer than the max runtime, plus the poller needs lots of memory
ini_set('max_execution_time', MAX_POLLER_RUNTIME + 1);
// initialize file creation flags
$change_files = false;
// obtain some defaults from the database
$max_threads = read_config_option('max_threads');
// Determine Command Name
$command_string = cacti_escapeshellcmd((string) read_config_option('path_php_binary'));
$extra_args = '-q ' . cacti_escapeshellarg(CACTI_PATH_BASE . '/cmd_realtime.php') . ' ' . cacti_escapeshellarg($poller_id) . " $graph_id $interval";
// Determine if Realtime will work or not
$cache_dir = read_config_option('realtime_cache_path');
if (!is_dir($cache_dir)) {
cacti_log("FATAL: Realtime Cache Directory '$cache_dir' Does Not Exist!");
return -1;
}
if (!is_writable($cache_dir)) {
cacti_log("FATAL: Realtime Cache Directory '$cache_dir' is Not Writable!");
return -2;
}
shell_exec("$command_string $extra_args");
// open a pipe to rrdtool for writing
$rrdtool_pipe = rrd_init();
// process poller output
process_poller_output_rt($rrdtool_pipe, $poller_id, $interval);
// close rrd
rrd_close($rrdtool_pipe);
// close db
db_close();
// display_version - displays version information
function display_version() : void {
$version = get_cacti_cli_version();
print "Cacti Realtime Poller, Version $version, " . COPYRIGHT_YEARS . "\n";
}
function display_help() : void {
display_version();
print "\nusage: poller_realtime.php --graph=ID [--interval=SEC] [--force] [--debug]\n\n";
print "Cacti's Realtime graphing poller. This poller behaves very similarly\n";
print "to Cacti's main poller with the exception that it only polls data source\n";
print "that are specific to the graph being rendered in the Cacti UI.\n\n";
print "Required:\n";
print " --graph=ID Specify the graph id to convert (realtime)\n\n";
print "Optional:\n";
print " --interval=SEC Specify the graph interval (realtime)\n";
print " --force Override poller overrun detection and force a poller run\n";
print " --debug|-d Output debug information. Similar to cacti's DEBUG logging level.\n\n";
}
// process_poller_output REAL TIME MODIFIED
function process_poller_output_rt(mixed $rrdtool_pipe, string $poller_id, int $interval) : int {
require_once(CACTI_PATH_LIBRARY . '/rrd.php');
// let's count the number of rrd files we processed
$rrds_processed = 0;
$rrd_update_array = [];
// create/update the rrd files
$results = db_fetch_assoc_prepared('SELECT port.output, port.time, port.local_data_id,
poller_item.rrd_path, poller_item.rrd_name, poller_item.rrd_num
FROM (poller_output_realtime AS port, poller_item)
WHERE (port.local_data_id=poller_item.local_data_id
AND port.rrd_name=poller_item.rrd_name)
AND port.poller_id = ?',
[$poller_id]);
if (cacti_sizeof($results) > 0) {
// create an array keyed off of each .rrd file
foreach ($results as $item) {
$rt_graph_path = read_config_option('realtime_cache_path') . '/user_' . $poller_id . '_' . $item['local_data_id'] . '.rrd';
$data_source_path = get_data_source_path($item['local_data_id'], true);
// create rt rrd
if (!file_exists($rt_graph_path)) {
// get the syntax
$command = @rrdtool_function_create($item['local_data_id'], true);
// replace path
$command = str_replace($data_source_path, $rt_graph_path, $command);
/**
* minimum refresh interval - this will always have a value of 1
* so that at the one second realtime interval, good numbers are
* returned to the user. We ignore the user setting.
*
* $step = read_config_option('realtime_interval');
*
*/
// replace step
$command = preg_replace('/--step\s(\d+)/', '--step 1', $command);
/* WIN32: before sending this command off to rrdtool, get rid
of all of the '\' characters. Unix does not care; win32 does.
Also make sure to replace all of the fancy "\"s at the end of the line,
but make sure not to get rid of the "\n"s that are supposed to be
in there (text format) */
$command = str_replace("\\\n", ' ', $command);
// create the rrdfile without going through a shell
$rrdtool = read_config_option('path_rrdtool') . ' ';
if (strpos($command, $rrdtool) === 0) {
$command = substr($command, strlen($rrdtool));
}
rrdtool_execute($command, false, RRDTOOL_OUTPUT_STDOUT, $rrdtool_pipe, 'POLLER');
// change permissions so that the poller can clear
@chmod($rt_graph_path, 0644);
} else {
// change permissions so that the poller can clear
@chmod($rt_graph_path, 0644);
}
// now, let's update the path to keep the RRDs updated
$item['rrd_path'] = $rt_graph_path;
// cleanup the value
$value = trim($item['output']);
$unix_time = strtotime($item['time']);
$rrd_update_array[$item['rrd_path']]['local_data_id'] = $item['local_data_id'];
// single one value output
if ((is_numeric($value)) || ($value == 'U')) {
$rrd_update_array[$item['rrd_path']]['times'][$unix_time][$item['rrd_name']] = $value;
} else {
// multiple value output
$values = preg_split('/\s+/', $value);
$rrd_field_names = array_rekey(
db_fetch_assoc_prepared('SELECT DISTINCT dtr.data_source_name, dif.data_name
FROM graph_templates_item AS gti
INNER JOIN data_template_rrd AS dtr
ON gti.task_item_id = dtr.id
INNER JOIN data_input_fields AS dif
ON dtr.data_input_field_id = dif.id
AND dtr.local_data_id = ?',
[$item['local_data_id']]),
'data_name', 'data_source_name'
);
if (cacti_sizeof($values)) {
foreach (($values ?: []) as $value) {
$matches = explode(':', $value);
if (isset($rrd_field_names[$matches[0]])) {
$rrd_update_array[$item['rrd_path']]['times'][$unix_time][$rrd_field_names[$matches[0]]] = $matches[1];
}
}
}
}
// fallback values
if ((!isset($rrd_update_array[$item['rrd_path']]['times'][$unix_time])) && ($item['rrd_name'] != '')) {
$rrd_update_array[$item['rrd_path']]['times'][$unix_time][$item['rrd_name']] = 'U';
} elseif ((!isset($rrd_update_array[$item['rrd_path']]['times'][$unix_time])) && ($item['rrd_name'] == '')) {
unset($rrd_update_array[$item['rrd_path']]);
}
}
// make sure each .rrd file has complete data
foreach ($results as $item) {
db_execute_prepared('DELETE FROM poller_output_realtime
WHERE local_data_id = ?
AND rrd_name = ?
AND time = ?
AND poller_id = ?',
[$item['local_data_id'], $item['rrd_name'], $item['time'], $poller_id]);
}
$rrds_processed = rrdtool_function_update($rrd_update_array, $rrdtool_pipe);
}
return $rrds_processed;
}