Files
rrdproxy/cli/removespikes.php
TheWitnessandThomas Vincent 629971cc73 Harden RRDtool proxy request handling (#20)
* Harden RRDtool proxy request handling

* Address PR review feedback: encrypt oversized-request errors, fix env inheritance, spacing, and symlink test

Addresses review comments on PR #17:
- lib/client.php: encrypt the 'Request too large' error frame once the
  client is authenticated, matching the existing encrypted-error pattern
- lib/client.php: build the removespikes child-process environment from
  getenv() instead of $_ENV so the full current environment (e.g. TEMP on
  Windows) is inherited, with RRDP_RRDTOOL_PATH merged on top
- lib/client.php: add the missing space in the RRDTOOL_PIPE_UNAVAILABLE
  error frame so it matches the other 'ERROR: ...' responses
- tests/run.php: check the symlink() fixture return value and skip the
  symlink-escape assertion (instead of producing a false positive) when
  symlink() is unavailable

* Upgrade phpseclib 3.x to phpseclib/phpseclib ^4.0

- composer.json/composer.lock: require phpseclib/phpseclib ^4.0 (was ^3.0)
- Update phpseclib3\\... namespace usages to phpseclib4\\... across
  rrdtool-proxy.php, lib/client.php, lib/functions.php, lib/wizard.php,
  and tests/run.php
- lib/functions.php: phpseclib4 removed the Random class, so
  Random::string(32) is replaced with random_bytes(32)
- Fix lib/__master__.php and lib/replicator.php, which PR #17 never
  migrated off the old pre-Composer phpseclib API (new \\phpseclib\\Crypt\\RSA(),
  ->loadKey(), ->getPublicKeyFingerprint() -- none of which exist in
  phpseclib 3.x or 4.x, so these MSR/replicator peer-authentication code
  paths would fatal at runtime). Both now use
  RSA::loadPublicKey()->getFingerprint('md5') wrapped in try/catch,
  matching the pattern already used in lib/client.php, plus a
  constant-time hash_equals() fingerprint comparison
- tests/run.php: relabel the phpseclib round-trip check from 'phpseclib 3'
  to 'phpseclib 4'

* Bump minimum PHP version to 8.2 and add CHANGELOG.md

- composer.json: require php ^8.2 (was ^8.1)
- include/global.php: bump RRDP_PHP_VERSION_REQUIRED to 80200 to match
- README.md: update minimum/recommended PHP version to 8.2
- composer.lock: refreshed for the new php platform constraint
- Add CHANGELOG.md tracking changes prefixed security:/issue:/feature:

* Add PHPDoc blocks to all functions

Adds a PHPDoc block above every function in rrdtool-proxy.php,
lib/functions.php, lib/client.php, lib/__master__.php, lib/replicator.php,
lib/wizard.php, cli/removespikes.php, tests/run.php, and
.phpstan-bootstrap.php, each with a brief description and inferred
@param/@return types. Function signatures themselves are unchanged; PHPStan
will flag any inferred types that turn out to be inaccurate.

* Add .github/copilot-instructions.md

Generated from README.md and this session's learnings: project architecture
(daemon entrypoint, shared lib helpers, client/master/replicator roles,
wizard, removespikes CLI), build/lint/test commands (composer scripts,
php tests/run.php, Windows platform-req caveats), and conventions (tabs,
GPL header block, PHPDoc-only typing, phpseclib4 gotchas, CHANGELOG entry
prefixes).

Also fix .gitignore: the '.git*' pattern was unintentionally also matching
and ignoring the .github/ directory; added a '!.github/' exception so files
under .github/ (like this one) are tracked.

* Add .gitattributes enforcing LF line endings; normalize existing CRLF files

- Add .gitattributes with '* text=auto eol=lf' so Git normalizes line
  endings to LF on checkout/commit regardless of local core.autocrlf
  settings.
- .gitignore: add '!.gitattributes' alongside the existing '!.github/'
  exception, since '.git*' was also unintentionally ignoring .gitattributes
  itself.
- Normalize the two tracked files that had CRLF line endings
  (.github/copilot-instructions.md, CHANGELOG.md) to LF; no other tracked
  files were affected.

* Manual PSR coding-style adjustments

Manual formatting/coding-style cleanup in cli/removespikes.php,
lib/__master__.php, lib/client.php, and lib/replicator.php.

* Address automated review feedback: symlink confinement, key-pair validation, and misc fixes

Addresses 10 unresolved copilot-pull-request-reviewer comments on PR #20:

- lib/functions.php: rewrite rrdp_resolve_path_within() to walk the path one
  segment at a time (instead of only checking the nearest existing ancestor
  via file_exists()), so a symlink anywhere along a not-yet-fully-existing
  path is resolved and confined even if the symlink itself is dangling.
- lib/functions.php: add rrdp_resolve_command_paths(), which resolves every
  RRD file path operand referenced by an RRDtool command (leading bare file
  arguments and DEF:/SDEF: clauses) against the RRA root and rewrites the
  command to use the canonical resolved paths, closing the symlink-escape
  gap left by the lexical-only rrdp_command_has_unsafe_path() check.
- lib/client.php: wire rrdp_resolve_command_paths() into the RRDtool command
  dispatch path, rejecting/rewriting unsafe operands before they reach the
  RRDtool pipe.
- lib/client.php: enforce the request-size limit on each completed
  transaction and the trailing unfinished remainder, instead of on the
  aggregate receive buffer before splitting on _EOT_, so multiple legitimate
  transactions in one read no longer trip a false 'Request too large'.
- rrdtool-proxy.php: validate at startup that the loaded public/private key
  files actually form a matching pair (comparing derived fingerprints)
  instead of only deriving a fingerprint from the public key, failing
  startup instead of accepting requests that will all fail to decrypt.
- lib/__master__.php, lib/replicator.php: resolve peer-supplied FULLSCAN
  folder/file names (and RRDLAST/RRDDUMP request filenames) through
  rrdp_resolve_rra_path() before using them in mkdir()/touch()/
  file_put_contents()/RRDtool restore-dump-last-flushcached operations,
  instead of concatenating raw peer-supplied text; reject and skip entries
  that fail confinement.
- lib/replicator.php, lib/__master__.php: guard rrdtool_pipe_init() failures
  in the main replication loop the same way lib/client.php already does,
  instead of indexing into a false return value and never retrying.
- cli/removespikes.php: make createRRDFileFromXML() return whether the
  RRDtool restore actually succeeded, and exit non-zero when it fails
  instead of silently exiting 0.
- .phpstan.neon: restore level 6 (was lowered to 2, contradicting
  .github/copilot-instructions.md) and add .phpstan-baseline.neon to
  capture the 42 pre-existing findings surfaced at level 6 without hiding
  future regressions.
- tests/run.php: add the standard Cacti Group GPL header block required for
  every PHP file.

---------

Co-authored-by: Thomas Vincent <thomasvincent@gmail.com>
2026-09-19 09:04:05 -04:00

1056 lines
34 KiB
PHP

#!/usr/bin/php -q
<?php
/*
ex: set tabstop=4 shiftwidth=4 autoindent:
+-------------------------------------------------------------------------+
| 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/ |
+-------------------------------------------------------------------------+
*/
// do NOT run this script through a web browser
if (!isset($_SERVER['argv'][0]) || isset($_SERVER['REQUEST_METHOD']) || isset($_SERVER['REMOTE_ADDR'])) {
die('<br><strong>This script is only meant to run at the command line.</strong>');
}
// We are not talking to the browser
$no_http_headers = true;
$dir = __DIR__;
chdir($dir);
if (strpos($dir, 'spikekill') !== false) {
chdir('../../');
}
// Start Initialization Section
if (file_exists('./include/global.php') & !file_exists('../rrdtool-proxy.php')) {
include_once('./include/global.php');
$using_cacti = true;
} else {
$using_cacti = false;
}
// setup defaults
$debug = false;
$dryrun = false;
$avgnan = 'avg';
$rrdfile = '';
$std_kills = false;
$var_kills = false;
$html = false;
$backup = false;
$config = [];
$new_output = [];
if ($using_cacti) {
$method = read_config_option('spikekill_method');
$numspike = read_config_option('spikekill_number');
$stddev = read_config_option('spikekill_deviations');
$percent = read_config_option('spikekill_percent');
$outliers = read_config_option('spikekill_outliers');
} else {
$method = 1; // Standard Deviation
$numspike = 100;
$stddev = 10;
$percent = 500;
$outliers = 5;
}
// process calling arguments
$parms = $_SERVER['argv'];
array_shift($parms);
foreach ($parms as $parameter) {
@[$arg, $value] = @explode('=', $parameter);
switch ($arg) {
case '--method':
case '-M':
if ($value == 'variance') {
$method = 2;
} elseif ($value == 'stddev') {
$method = 1;
} else {
print "FATAL: You must specify either 'stddev' or 'variance' as methods.\n\n";
display_help();
exit(-11);
}
break;
case '--avgnan':
case '-A':
if ($value == 'avg') {
$avgnan = 'avg';
} elseif ($value == 'nan') {
$avgnan = 'nan';
} else {
print "FATAL: You must specify either 'avg' or 'nan' as replacement methods.\n\n";
display_help();
exit(-10);
}
break;
case '--rrdfile':
case '-R':
$rrdfile = $value;
if (!file_exists($rrdfile)) {
print "FATAL: File '$rrdfile' does not exist.\n";
exit(-9);
}
if (!is_writable($rrdfile)) {
print "FATAL: File '$rrdfile' is not writable by this account.\n";
exit(-8);
}
break;
case '--stddev':
case '-S':
$stddev = $value;
if (!is_numeric($stddev) || ($stddev < 1)) {
print "FATAL: Standard Deviation must be a positive integer.\n\n";
display_help();
exit(-7);
}
break;
case '--outliers':
case '-O':
$outliers = $value;
if (!is_numeric($outliers) || ($outliers < 1)) {
print "FATAL: The number of outliers to exclude must be a positive integer.\n\n";
display_help();
exit(-6);
}
break;
case '--percent':
case '-P':
$percent = $value;
if (!is_numeric($percent) || ($percent <= 0)) {
print "FATAL: Percent deviation must be a positive floating point number.\n\n";
display_help();
exit(-5);
}
break;
case '--html':
$html = true;
break;
case '--backup':
$backup = true;
break;
case '-d':
case '--debug':
$debug = true;
break;
case '-D':
case '--dryrun':
$dryrun = true;
break;
case '--number':
case '-N':
case '-n':
$numspike = $value;
if (!is_numeric($numspike) || ($numspike < 1)) {
print "FATAL: Number of spikes to remove must be a positive integer\n\n";
display_help();
exit(-4);
}
break;
case '-h':
case '-v':
case '-V':
case '--version':
case '--help':
display_help();
exit(0);
default:
print 'ERROR: Invalid Parameter ' . $parameter . "\n\n";
display_help();
exit(-3);
}
}
// additional error check
if ($rrdfile == '') {
print "FATAL: You must specify an RRDfile!\n\n";
display_help();
exit(-2);
}
// let's see if we can find rrdtool
$rrdtool_path = getenv('RRDP_RRDTOOL_PATH');
if ($rrdtool_path === false || $rrdtool_path === '') {
$rrdtool_path = $using_cacti ? read_config_option('path_rrdtool') : (substr_count(PHP_OS, 'WIN') ? 'rrdtool.exe' : 'rrdtool');
}
if (!$using_cacti) {
$response = removespikes_run_rrdtool($rrdtool_path, []);
if ($response !== false && strlen($response['stdout'] . $response['stderr'])) {
$response = $response['stdout'] . $response['stderr'];
$response_array = explode(' ', $response);
print 'NOTE: Using ' . $response_array[0] . ' Version ' . $response_array[1] . "\n";
} else {
print "FATAL: RRDTool not found in path. Please insure RRDTool can be found in your path!\n";
exit(-1);
}
}
// determine the temporary file name
$seed = mt_rand();
if ($using_cacti) {
if ($config['cacti_server_os'] == 'win32') {
$tempdir = getenv('TEMP');
$xmlfile = tempnam($tempdir, 'rrdproxy-dump-');
$bakfile = $tempdir . '/' . str_replace('.rrd', '', basename($rrdfile)) . '.backup.' . $seed . '.rrd';
} else {
$tempdir = '/tmp';
$xmlfile = tempnam($tempdir, 'rrdproxy-dump-');
$bakfile = '/tmp/' . str_replace('.rrd', '', basename($rrdfile)) . '.backup.' . $seed . '.rrd';
}
} elseif (substr_count(PHP_OS, 'WIN')) {
$tempdir = getenv('TEMP');
$xmlfile = tempnam($tempdir, 'rrdproxy-dump-');
$bakfile = $tempdir . '/' . str_replace('.rrd', '', basename($rrdfile)) . '.backup.' . $seed . '.rrd';
} else {
$tempdir = '/tmp';
$xmlfile = tempnam($tempdir, 'rrdproxy-dump-');
$bakfile = '/tmp/' . str_replace('.rrd', '', basename($rrdfile)) . '.backup.' . $seed . '.rrd';
}
if ($xmlfile === false) {
print "FATAL: Unable to create a secure temporary file.\n";
exit(-12);
}
if ($html) {
print "<table cellpadding='3' cellspacing='0' class='spikekill_data' id='spikekill_data'>";
}
if ($using_cacti) {
cacti_log("NOTE: Removing Spikes for '$rrdfile', Method:'$method'", false, 'WEBUI');
}
// execute the dump command
print ($html ? "<tr><td colspan='20' class='spikekill_note'>" : '') . "NOTE: Creating XML file '$xmlfile' from '$rrdfile'" . ($html ? "</td></tr>\n" : "\n");
$dump_result = removespikes_run_rrdtool($rrdtool_path, ['dump', $rrdfile]);
if ($dump_result !== false && $dump_result['status'] === 0 && $dump_result['stdout'] !== '') {
file_put_contents($xmlfile, $dump_result['stdout'], LOCK_EX);
} else {
unlink($xmlfile);
}
// read the xml file into an array
if (file_exists($xmlfile)) {
$output = file($xmlfile);
// remove the temp file
unlink($xmlfile);
} else {
if ($using_cacti) {
print ($html ? "<tr><td colspan='20' class='spikekill_note'>" : '') . 'FATAL: RRDtool Command Failed. Please verify that the RRDtool path is valid in Settings->Paths!' . ($html ? "</td></tr>\n" : "\n");
} else {
print ($html ? "<tr><td colspan='20' class='spikekill_note'>" : '') . 'FATAL: RRDtool Command Failed. Please insure your RRDtool install is valid!' . ($html ? "</td></tr>\n" : "\n");
}
exit(-12);
}
// backup the rrdfile if requested
if ($backup && !$dryrun) {
if (copy($rrdfile, $bakfile)) {
print ($html ? "<tr><td colspan='20' class='spikekill_note'>" : '') . "NOTE: RRDfile '$rrdfile' backed up to '$bakfile'" . ($html ? "</td></tr>\n" : "\n");
} else {
print ($html ? "<tr><td colspan='20' class='spikekill_note'>" : '') . "FATAL: RRDfile Backup of '$rrdfile' to '$bakfile' FAILED!" . ($html ? "</td></tr>\n" : "\n");
exit(-13);
}
}
// process the xml file and remove all comments
$output = removeComments($output);
/* Read all the rra's ds values and obtain the following pieces of information from each
rra archive.
* numsamples - The number of 'valid' non-nan samples
* sumofsamples - The sum of all 'valid' samples.
* average - The average of all samples
* standard_deviation - The standard deviation of all samples
* max_value - The maximum value of all samples
* min_value - The minimum value of all samples
* max_cutoff - Any value above this value will be set to the average.
* min_cutoff - Any value lower than this value will be set to the average.
This will end up being a n-dimensional array as follows:
rra[x][ds#]['totalsamples'];
rra[x][ds#]['numsamples'];
rra[x][ds#]['sumofsamples'];
rra[x][ds#]['average'];
rra[x][ds#]['stddev'];
rra[x][ds#]['max_value'];
rra[x][ds#]['min_value'];
rra[x][ds#]['max_cutoff'];
rra[x][ds#]['min_cutoff'];
There will also be a secondary array created with the actual samples. This
array will be used to calculate the standard deviation of the sample set.
samples[rra_num][ds_num][];
Also track the min and max value for each ds and store it into the two
arrays: ds_min[ds#], ds_max[ds#].
The we don't need to know the type of rra, only it's number for this analysis
the same applies for the ds' as well.
*/
$rra = [];
$rra_cf = [];
$rra_pdp = [];
$rra_num = 0;
$ds_num = 0;
$total_kills = 0;
$in_rra = false;
$in_db = false;
$ds_min = [];
$ds_max = [];
$ds_name = [];
/* perform a first pass on the array and do the following:
1) Get the number of good samples per ds
2) Get the sum of the samples per ds
3) Get the max and min values for all samples
4) Build both the rra and sample arrays
5) Get each ds' min and max values
*/
if (sizeof($output)) {
foreach ($output as $line) {
if (substr_count($line, '<v>')) {
$linearray = explode('<v>', $line);
// discard the row
array_shift($linearray);
$ds_num = 0;
foreach ($linearray as $dsvalue) {
// peel off garbage
$dsvalue = trim(str_replace('</row>', '', str_replace('</v>', '', $dsvalue)));
if (strtolower($dsvalue) != 'nan') {
if (!isset($rra[$rra_num][$ds_num]['numsamples'])) {
$rra[$rra_num][$ds_num]['numsamples'] = 1;
} else {
$rra[$rra_num][$ds_num]['numsamples']++;
}
if (!isset($rra[$rra_num][$ds_num]['sumofsamples'])) {
$rra[$rra_num][$ds_num]['sumofsamples'] = $dsvalue;
} else {
$rra[$rra_num][$ds_num]['sumofsamples'] += $dsvalue;
}
if (!isset($rra[$rra_num][$ds_num]['max_value'])) {
$rra[$rra_num][$ds_num]['max_value'] = $dsvalue;
} elseif ($dsvalue > $rra[$rra_num][$ds_num]['max_value']) {
$rra[$rra_num][$ds_num]['max_value'] = $dsvalue;
}
if (!isset($rra[$rra_num][$ds_num]['min_value'])) {
$rra[$rra_num][$ds_num]['min_value'] = $dsvalue;
} elseif ($dsvalue < $rra[$rra_num][$ds_num]['min_value']) {
$rra[$rra_num][$ds_num]['min_value'] = $dsvalue;
}
// store the sample for standard deviation calculation
$samples[$rra_num][$ds_num][] = $dsvalue;
}
if (!isset($rra[$rra_num][$ds_num]['totalsamples'])) {
$rra[$rra_num][$ds_num]['totalsamples'] = 1;
} else {
$rra[$rra_num][$ds_num]['totalsamples']++;
}
$ds_num++;
}
} elseif (substr_count($line, '<rra>')) {
$in_rra = true;
} elseif (substr_count($line, '<min>')) {
$ds_min[] = trim(str_replace('<min>', '', str_replace('</min>', '', trim($line))));
} elseif (substr_count($line, '<max>')) {
$ds_max[] = trim(str_replace('<max>', '', str_replace('</max>', '', trim($line))));
} elseif (substr_count($line, '<name>')) {
$ds_name[] = trim(str_replace('<name>', '', str_replace('</name>', '', trim($line))));
} elseif (substr_count($line, '<cf>')) {
$rra_cf[] = trim(str_replace('<cf>', '', str_replace('</cf>', '', trim($line))));
} elseif (substr_count($line, '<pdp_per_row>')) {
$rra_pdp[] = trim(str_replace('<pdp_per_row>', '', str_replace('</pdp_per_row>', '', trim($line))));
} elseif (substr_count($line, '</rra>')) {
$in_rra = false;
$rra_num++;
} elseif (substr_count($line, '<step>')) {
$step = trim(str_replace('<step>', '', str_replace('</step>', '', trim($line))));
}
}
}
// For all the samples determine the average with the outliers removed
calculateVarianceAverages($rra, $samples);
/* Now scan the rra array and the samples array and calculate the following
1) The standard deviation of all samples
2) The average of all samples per ds
3) The max and min cutoffs of all samples
4) The number of kills in each ds based upon the thresholds
*/
print ($html ? "<tr><td colspan='20' class='spikekill_note'>" : '') . "NOTE: Searching for Spikes in XML file '$xmlfile'" . ($html ? "</td></tr>\n" : "\n");
calculateOverallStatistics($rra, $samples);
// debugging and/or status report
if ($debug || $dryrun) {
outputStatistics($rra);
}
// create an output array
if ($method == 1) {
// standard deviation subroutine
if ($std_kills) {
if (!$dryrun) {
$new_output = updateXML($output, $rra);
}
} else {
print ($html ? "<tr><td colspan='20' class='spikekill_note'>" : '') . "NOTE: NO Standard Deviation Spikes found in '$rrdfile'" . ($html ? "</td></tr>\n" : "\n");
}
} else {
// variance subroutine
if ($var_kills) {
if (!$dryrun) {
$new_output = updateXML($output, $rra);
}
} else {
print ($html ? "<tr><td colspan='20' class='spikekill_note'>" : '') . "NOTE: NO Variance Spikes found in '$rrdfile'" . ($html ? "</td></tr>\n" : "\n");
}
}
// finally update the file XML file and Reprocess the RRDfile
if (!$dryrun) {
if ($total_kills) {
if (writeXMLFile($new_output, $xmlfile)) {
if (backupRRDFile($rrdfile)) {
if (!createRRDFileFromXML($xmlfile, $rrdfile)) {
print ($html ? "<tr><td colspan='20' class='spikekill_note'>" : '') . "FATAL: Unable to restore '$rrdfile' from '$xmlfile'" . ($html ? "</td></tr>\n" : "\n");
exit(-14);
}
} else {
print ($html ? "<tr><td colspan='20' class='spikekill_note'>" : '') . "FATAL: Unable to backup '$rrdfile'" . ($html ? "</td></tr>\n" : "\n");
}
} else {
print ($html ? "<tr><td colspan='20' class='spikekill_note'>" : '') . "FATAL: Unable to write XML file '$xmlfile'" . ($html ? "</td></tr>\n" : "\n");
}
}
} else {
print ($html ? "<tr><td colspan='20' class='spikekill_note'>" : '') . 'NOTE: Dryrun requested. No updates performed' . ($html ? "</td></tr>\n" : "\n");
}
if ($html) {
print '</table>';
}
// All Functions
/**
* Restores an RRD file from its (spike-cleaned) XML dump via `rrdtool restore`.
*
* @param string $xmlfile
* @param string $rrdfile
*
* @return bool
*/
function createRRDFileFromXML($xmlfile, $rrdfile) {
global $html, $rrdtool_path;
// execute the dump command
print ($html ? "<tr><td colspan='20' class='spikekill_note'>" : '') . "NOTE: Re-Importing '$xmlfile' to '$rrdfile'" . ($html ? "</td></tr>\n" : "\n");
$result = removespikes_run_rrdtool($rrdtool_path, ['restore', '-f', '-r', $xmlfile, $rrdfile]);
$response = $result === false ? 'Unable to start RRDtool' : $result['stdout'] . $result['stderr'];
if (strlen($response)) {
print ($html ? "<tr><td colspan='20' class='spikekill_note'>" : '') . $response . ($html ? "</td></tr>\n" : "\n");
}
return $result !== false && $result['status'] === 0;
}
/**
* Runs the rrdtool binary with the given argv-style arguments (no shell
* interpolation) and captures its combined stdout/stderr and exit status.
*
* @param string $binary
* @param array<int, string> $arguments
*
* @return array{status: int, stdout: string, stderr: string}|false
*/
function removespikes_run_rrdtool($binary, $arguments) {
$descriptors = [
1 => ['pipe', 'w'],
2 => ['redirect', 1],
];
$process = proc_open(array_merge([$binary], $arguments), $descriptors, $pipes);
if (!is_resource($process)) {
return false;
}
$stdout = stream_get_contents($pipes[1]);
fclose($pipes[1]);
$status = proc_close($process);
return ['status' => $status, 'stdout' => $stdout, 'stderr' => ''];
}
/**
* Writes the (spike-cleaned) XML dump lines out to $xmlfile.
*
* @param array<int, string> $output
* @param string $xmlfile
*
* @return int|false
*/
function writeXMLFile($output, $xmlfile) {
return file_put_contents($xmlfile, $output);
}
/**
* Copies $rrdfile into the configured (or temp) backup directory before it is
* modified, appending a random seed to the filename if a file of that name
* already exists there.
*
* @param string $rrdfile
*
* @return bool
*/
function backupRRDFile($rrdfile) {
global $using_cacti, $tempdir, $seed, $html;
if ($using_cacti) {
$backupdir = read_config_option('spikekill_backupdir');
if ($backupdir == '') {
$backupdir = $tempdir;
}
} else {
$backupdir = $tempdir;
}
if (file_exists($backupdir . '/' . basename($rrdfile))) {
$newfile = basename($rrdfile) . '.' . $seed;
} else {
$newfile = basename($rrdfile);
}
print ($html ? "<tr><td colspan='20' class='spikekill_note'>" : '') . "NOTE: Backing Up '$rrdfile' to '" . $backupdir . '/' . $newfile . "'" . ($html ? "</td></tr>\n" : "\n");
return copy($rrdfile, $backupdir . '/' . $newfile);
}
/**
* Computes each RRA/data-source's outlier-trimmed "variance" average (the mean
* of the samples after removing the top/bottom $outliers extremes), storing it
* into $rra[$rra_num][$ds_num]['variance_avg'].
*
* @param array<int, array<int, array<string, mixed>>> $rra
* @param array<int, array<int, array<int, float|int>>> $samples
*
* @return void
*/
function calculateVarianceAverages(&$rra, &$samples) {
global $outliers;
if (sizeof($samples)) {
foreach ($samples as $rra_num => $dses) {
if (sizeof($dses)) {
foreach ($dses as $ds_num => $ds) {
if (sizeof($ds) < $outliers * 3) {
$rra[$rra_num][$ds_num]['variance_avg'] = 'NAN';
} else {
rsort($ds, SORT_NUMERIC);
$ds = array_slice($ds, $outliers);
sort($ds, SORT_NUMERIC);
$ds = array_slice($ds, $outliers);
$rra[$rra_num][$ds_num]['variance_avg'] = array_sum($ds) / sizeof($ds);
}
}
}
}
}
}
/**
* For every RRA/data-source, computes the standard deviation, average, and
* min/max cutoffs from the collected samples, then counts (and flags via
* $std_kills/$var_kills) how many samples fall outside the standard-deviation
* and variance thresholds.
*
* @param array<int, array<int, array<string, mixed>>> $rra
* @param array<int, array<int, array<int, float|int>>> $samples
*
* @return void
*/
function calculateOverallStatistics(&$rra, &$samples) {
global $percent, $stddev, $ds_min, $ds_max, $var_kills, $std_kills;
$rra_num = 0;
if (sizeof($rra)) {
foreach ($rra as $dses) {
$ds_num = 0;
if (sizeof($dses)) {
foreach ($dses as $ds) {
if (isset($samples[$rra_num][$ds_num])) {
$rra[$rra_num][$ds_num]['standard_deviation'] = standard_deviation($samples[$rra_num][$ds_num]);
if ($rra[$rra_num][$ds_num]['standard_deviation'] == 'NAN') {
$rra[$rra_num][$ds_num]['standard_deviation'] = 0;
}
$rra[$rra_num][$ds_num]['average'] = $rra[$rra_num][$ds_num]['sumofsamples'] / $rra[$rra_num][$ds_num]['numsamples'];
$rra[$rra_num][$ds_num]['min_cutoff'] = $rra[$rra_num][$ds_num]['average'] - ($stddev * $rra[$rra_num][$ds_num]['standard_deviation']);
if ($rra[$rra_num][$ds_num]['min_cutoff'] < $ds_min[$ds_num]) {
$rra[$rra_num][$ds_num]['min_cutoff'] = $ds_min[$ds_num];
}
$rra[$rra_num][$ds_num]['max_cutoff'] = $rra[$rra_num][$ds_num]['average'] + ($stddev * $rra[$rra_num][$ds_num]['standard_deviation']);
if ($rra[$rra_num][$ds_num]['max_cutoff'] > $ds_max[$ds_num]) {
$rra[$rra_num][$ds_num]['max_cutoff'] = $ds_max[$ds_num];
}
$rra[$rra_num][$ds_num]['numnksamples'] = 0;
$rra[$rra_num][$ds_num]['sumnksamples'] = 0;
$rra[$rra_num][$ds_num]['avgnksamples'] = 0;
// go through values and find cutoffs
$rra[$rra_num][$ds_num]['stddev_killed'] = 0;
$rra[$rra_num][$ds_num]['variance_killed'] = 0;
if (sizeof($samples[$rra_num][$ds_num])) {
foreach ($samples[$rra_num][$ds_num] as $sample) {
if (($sample > $rra[$rra_num][$ds_num]['max_cutoff']) ||
($sample < $rra[$rra_num][$ds_num]['min_cutoff'])) {
debug(sprintf("Std Kill: Value '%.4e', StandardDev '%.4e', StdDevLimit '%.4e'", $sample, $rra[$rra_num][$ds_num]['standard_deviation'], ($rra[$rra_num][$ds_num]['max_cutoff'] * (1 + $percent))));
$rra[$rra_num][$ds_num]['stddev_killed']++;
$std_kills = true;
} else {
$rra[$rra_num][$ds_num]['numnksamples']++;
$rra[$rra_num][$ds_num]['sumnksamples'] += $sample;
}
if ($rra[$rra_num][$ds_num]['variance_avg'] == 'NAN') {
// not enough samples to calculate
} elseif ($sample > ($rra[$rra_num][$ds_num]['variance_avg'] * (1 + $percent))) {
// kill based upon variance
debug(sprintf("Var Kill: Value '%.4e', VarianceDev '%.4e', VarianceLimit '%.4e'", $sample, $rra[$rra_num][$ds_num]['variance_avg'], ($rra[$rra_num][$ds_num]['variance_avg'] * (1 + $percent))));
$rra[$rra_num][$ds_num]['variance_killed']++;
$var_kills = true;
}
}
}
if ($rra[$rra_num][$ds_num]['numnksamples'] > 0) {
$rra[$rra_num][$ds_num]['avgnksamples'] = $rra[$rra_num][$ds_num]['sumnksamples'] / $rra[$rra_num][$ds_num]['numnksamples'];
}
} else {
$rra[$rra_num][$ds_num]['standard_deviation'] = 'N/A';
$rra[$rra_num][$ds_num]['average'] = 'N/A';
$rra[$rra_num][$ds_num]['min_cutoff'] = 'N/A';
$rra[$rra_num][$ds_num]['max_cutoff'] = 'N/A';
$rra[$rra_num][$ds_num]['numnksamples'] = 'N/A';
$rra[$rra_num][$ds_num]['sumnksamples'] = 'N/A';
$rra[$rra_num][$ds_num]['avgnksamples'] = 'N/A';
$rra[$rra_num][$ds_num]['stddev_killed'] = 'N/A';
$rra[$rra_num][$ds_num]['variance_killed'] = 'N/A';
$rra[$rra_num][$ds_num]['stddev_killed'] = 'N/A';
$rra[$rra_num][$ds_num]['numnksamples'] = 'N/A';
$rra[$rra_num][$ds_num]['sumnksamples'] = 'N/A';
$rra[$rra_num][$ds_num]['variance_killed'] = 'N/A';
$rra[$rra_num][$ds_num]['avgnksamples'] = 'N/A';
}
$ds_num++;
}
}
$rra_num++;
}
}
}
/**
* Prints a per-data-source summary table (plain text or HTML) of the computed
* statistics and kill counts for each RRA.
*
* @param array<int, array<int, array<string, mixed>>> $rra
*
* @return void
*/
function outputStatistics($rra) {
global $rra_cf, $rra_name, $ds_name, $rra_pdp, $html;
if (sizeof($rra)) {
if (!$html) {
print "\n";
printf("%10s %16s %10s %7s %7s %10s %10s %10s %10s %10s %10s %10s %10s %10s %10s\n",
'Size', 'DataSource', 'CF', 'Samples', 'NonNan', 'Avg', 'StdDev',
'MaxValue', 'MinValue', 'MaxStdDev', 'MinStdDev', 'StdKilled', 'VarKilled', 'StdDevAvg', 'VarAvg');
printf("%10s %16s %10s %7s %7s %10s %10s %10s %10s %10s %10s %10s %10s %10s %10s\n",
'----------', '---------------', '----------', '-------', '-------', '----------', '----------', '----------',
'----------', '----------', '----------', '----------', '----------', '----------',
'----------');
foreach ($rra as $rra_key => $dses) {
if (sizeof($dses)) {
foreach ($dses as $dskey => $ds) {
printf('%10s %16s %10s %7s %7s ' .
($ds['average'] < 1E6 ? '%10s ' : '%10.4e ') .
($ds['standard_deviation'] < 1E6 ? '%10s ' : '%10.4e ') .
(isset($ds['max_value']) ? ($ds['max_value'] < 1E6 ? '%10s ' : '%10.4e ') : '%10s ') .
(isset($ds['min_value']) ? ($ds['min_value'] < 1E6 ? '%10s ' : '%10.4e ') : '%10s ') .
(isset($ds['max_cutoff']) ? ($ds['max_cutoff'] < 1E6 ? '%10s ' : '%10.4e ') : '%10s ') .
(isset($ds['min_cutoff']) ? ($ds['min_cutoff'] < 1E6 ? '%10s ' : '%10.4e ') : '%10s ') .
'%10s %10s ' .
(isset($ds['avgnksampled']) ? ($ds['avgnksamples'] < 1E6 ? '%10s ' : '%10.4e ') : '%10s ') .
(isset($ds['variance_avg']) ? ($ds['variance_avg'] < 1E6 ? '%10s ' : '%10.4e ') : '%10s ') . "\n",
displayTime($rra_pdp[$rra_key]),
$ds_name[$dskey],
$rra_cf[$rra_key],
$ds['totalsamples'],
(isset($ds['numsamples']) ? $ds['numsamples'] : '0'),
($ds['average'] != 'N/A' ? round($ds['average'],2) : $ds['average']),
($ds['standard_deviation'] != 'N/A' ? round($ds['standard_deviation'],2) : $ds['standard_deviation']),
(isset($ds['max_value']) ? round($ds['max_value'],2) : 'N/A'),
(isset($ds['min_value']) ? round($ds['min_value'],2) : 'N/A'),
($ds['max_cutoff'] != 'N/A' ? round($ds['max_cutoff'],2) : $ds['max_cutoff']),
($ds['min_cutoff'] != 'N/A' ? round($ds['min_cutoff'],2) : $ds['min_cutoff']),
$ds['stddev_killed'],
$ds['variance_killed'],
($ds['avgnksamples'] != 'N/A' ? round($ds['avgnksamples'],2) : $ds['avgnksamples']),
(isset($ds['variance_avg']) ? round($ds['variance_avg'],2) : 'N/A'));
}
}
}
print "\n";
} else {
printf("<tr><th style='width:10%%;'>%s</th><th>%s</th><th>%s</th><th>%s</th><th>%s</th><th>%s</th><th>%s</th><th>%s</th><th>%s</th><th>%s</th><th>%s</th><th>%s</th><th>%s</th><th>%s</th><th>%s</th></tr>\n",
'Size', 'DataSource', 'CF', 'Samples', 'NonNan', 'Avg', 'StdDev',
'MaxValue', 'MinValue', 'MaxStdDev', 'MinStdDev', 'StdKilled', 'VarKilled', 'StdDevAvg', 'VarAvg');
foreach ($rra as $rra_key => $dses) {
if (sizeof($dses)) {
foreach ($dses as $dskey => $ds) {
printf('<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>' .
($ds['average'] < 1E6 ? '%s</td><td>' : '%.4e</td><td>') .
($ds['standard_deviation'] < 1E6 ? '%s</td><td>' : '%.4e</td><td>') .
(isset($ds['max_value']) ? ($ds['max_value'] < 1E6 ? '%s</td><td>' : '%.4e</td><td>') : '%s</td><td>') .
(isset($ds['min_value']) ? ($ds['min_value'] < 1E6 ? '%s</td><td>' : '%.4e</td><td>') : '%s</td><td>') .
(isset($ds['max_cutoff']) ? ($ds['max_cutoff'] < 1E6 ? '%s</td><td>' : '%.4e</td><td>') : '%s</td><td>') .
(isset($ds['min_cutoff']) ? ($ds['min_cutoff'] < 1E6 ? '%s</td><td>' : '%.4e</td><td>') : '%s</td><td>') .
'%s</td><td>%s</td><td>' .
(isset($ds['avgnksampled']) ? ($ds['avgnksamples'] < 1E6 ? '%s</td><td>' : '%.4e</td><td>') : '%s</td><td>') .
(isset($ds['variance_avg']) ? ($ds['variance_avg'] < 1E6 ? "%s</td></tr>\n" : "%.4e</td></tr>\n") : "%s</td></tr>\n") . "\n",
displayTime($rra_pdp[$rra_key]),
$ds_name[$dskey],
$rra_cf[$rra_key],
$ds['totalsamples'],
(isset($ds['numsamples']) ? $ds['numsamples'] : '0'),
($ds['average'] != 'N/A' ? round($ds['average'],2) : $ds['average']),
($ds['standard_deviation'] != 'N/A' ? round($ds['standard_deviation'],2) : $ds['standard_deviation']),
(isset($ds['max_value']) ? round($ds['max_value'],2) : 'N/A'),
(isset($ds['min_value']) ? round($ds['min_value'],2) : 'N/A'),
($ds['max_cutoff'] != 'N/A' ? round($ds['max_cutoff'],2) : $ds['max_cutoff']),
($ds['min_cutoff'] != 'N/A' ? round($ds['min_cutoff'],2) : $ds['min_cutoff']),
$ds['stddev_killed'],
$ds['variance_killed'],
($ds['avgnksamples'] != 'N/A' ? round($ds['avgnksamples'],2) : $ds['avgnksamples']),
(isset($ds['variance_avg']) ? round($ds['variance_avg'],2) : 'N/A'));
}
}
}
}
}
}
/**
* Rewrites the dumped XML's <row> value lines, replacing any sample outside
* the computed thresholds with the RRA's average (or 'NaN'), up to $numspike
* replacements per RRA.
*
* @param array<int, string> $output
* @param array<int, array<int, array<string, mixed>>> $rra
*
* @return array<int, string>
*/
function updateXML(&$output, &$rra) {
global $numspike, $percent, $avgnan, $method, $total_kills;
// variance subroutine
$rra_num = 0;
$ds_num = 0;
$kills = 0;
$new_array = [];
if (sizeof($output)) {
foreach ($output as $line) {
if (substr_count($line, '<v>')) {
$linearray = explode('<v>', $line);
// discard the row
array_shift($linearray);
// initialize variables
$ds_num = 0;
$out_row = '<row>';
foreach ($linearray as $dsvalue) {
// peel off garbage
$dsvalue = trim(str_replace('</row>', '', str_replace('</v>', '', $dsvalue)));
if (strtolower($dsvalue) == 'nan') {
// do nothing, it's a NaN
} else {
if ($method == 2) {
if ($dsvalue > (1 + $percent) * $rra[$rra_num][$ds_num]['variance_avg']) {
if ($kills < $numspike) {
if ($avgnan == 'avg') {
$dsvalue = $rra[$rra_num][$ds_num]['variance_avg'];
} else {
$dsvalue = 'NaN';
}
$kills++;
$total_kills++;
}
}
} else {
if (($dsvalue > $rra[$rra_num][$ds_num]['max_cutoff']) ||
($dsvalue < $rra[$rra_num][$ds_num]['min_cutoff'])) {
if ($kills < $numspike) {
if ($avgnan == 'avg') {
$dsvalue = $rra[$rra_num][$ds_num]['average'];
} else {
$dsvalue = 'NaN';
}
$kills++;
$total_kills++;
}
}
}
}
$out_row .= '<v> ' . $dsvalue . '</v>';
$ds_num++;
}
$out_row .= '</row>';
$new_array[] = $out_row;
} else {
if (substr_count($line, '</rra>')) {
$ds_minmax = [];
$rra_num++;
$kills = 0;
} elseif (substr_count($line, '</database>')) {
$ds_num++;
$kills = 0;
}
$new_array[] = $line;
}
}
}
return $new_array;
}
/**
* Strips blank lines and `<!-- ... -->` XML comments from a dumped RRD file's
* lines.
*
* @param array<int, string> $output
*
* @return array<int, string>|null
*/
function removeComments(&$output) {
$new_array = [];
if (sizeof($output)) {
foreach ($output as $line) {
$line = trim($line);
if ($line == '') {
continue;
} else {
// is there a comment, remove it
$comment_start = strpos($line, '<!--');
if ($comment_start === false) {
// do nothing no line
} else {
$comment_end = strpos($line, '-->');
if ($comment_start == 0) {
$line = trim(substr($line, $comment_end + 3));
} else {
$line = trim(substr($line,0,$comment_start - 1) . substr($line,$comment_end + 3));
}
}
if ($line != '') {
$new_array[] = $line;
}
}
}
// transfer the new array back to the original array
return $new_array;
}
}
/**
* Formats a PDP (primary data point) count as a human-readable duration
* (seconds, minutes, hours, or days) based on the RRD's step size.
*
* @param int $pdp
*
* @return string
*/
function displayTime($pdp) {
global $step;
$total_time = $pdp * $step; // seconds
if ($total_time < 60) {
return $total_time . ' secs';
} else {
$total_time = $total_time / 60;
if ($total_time < 60) {
return $total_time . ' mins';
} else {
$total_time = $total_time / 60;
if ($total_time < 24) {
return $total_time . ' hours';
} else {
$total_time = $total_time / 24;
return $total_time . ' days';
}
}
}
}
/**
* Prints $string prefixed with "DEBUG: " when debug output is enabled.
*
* @param string $string
*
* @return void
*/
function debug($string) {
global $debug;
if ($debug) {
print 'DEBUG: ' . $string . "\n";
}
}
/**
* Computes the (population) standard deviation of a set of numeric samples.
*
* @param array<int, float|int> $samples
*
* @return float
*/
function standard_deviation($samples) {
$sample_count = count($samples);
$sample_square = [];
for ($current_sample = 0; $sample_count > $current_sample; ++$current_sample) {
$sample_square[$current_sample] = pow($samples[$current_sample], 2);
}
return sqrt(array_sum($sample_square) / $sample_count - pow((array_sum($samples) / $sample_count), 2));
}
// display_help - displays the usage of the function
/**
* Prints removespikes' version banner and command-line usage/help text.
*
* @return void
*/
function display_help() {
global $using_cacti;
if ($using_cacti) {
$version = spikekill_version();
} else {
$version = 'v1.1';
}
print 'Cacti Spike Remover ' . ($using_cacti ? 'v' . $version['version'] : $version) . ", Copyright 2004-2026, The Cacti Group, Inc.\n\n";
print "Usage:\n";
print "removespikes.php -R|--rrdfile=rrdfile [-M|--method=stddev] [-A|--avgnan] [-S|--stddev=N]\n";
print " [-P|--percent=N] [-N|--number=N] [-D|--dryrun] [-d|--debug]\n";
print " [--html] [-h|--help|-v|-V|--version]\n\n";
print "The RRDfile input parameter is mandatory. If no other input parameters are specified the defaults\n";
print "are taken from the Spikekill Plugin settings.\n\n";
print "-M|--method - The spike removal method to use. Options are 'stddev'|'variance'\n";
print "-A|--avgnan - The spike replacement method to use. Options are 'avg'|'nan'\n";
print "-S|--stddev - The number of standard deviations +/- allowed\n";
print "-P|--percent - The sample to sample percentage variation allowed\n";
print "-N|--number - The maximum number of spikes to remove from the RRDfile\n";
print "-D|--dryrun - If specified, the RRDfile will not be changed. Instead a summary of\n";
print " changes that would have been performed will be issued.\n";
print "--backup - Backup the original RRDfile to preserve prior values.\n\n";
print "The remainder of arguments are informational\n";
print "--html - Format the output for a web browser\n";
print "-d|--debug - Display verbose output during execution\n";
print "-v -V --version - Display this help message\n";
print "-h --help - display this help message\n";
}