1<?php 2/** 3 * Rrd.php 4 * 5 * -Description- 6 * 7 * This program is free software: you can redistribute it and/or modify 8 * it under the terms of the GNU General Public License as published by 9 * the Free Software Foundation, either version 3 of the License, or 10 * (at your option) any later version. 11 * 12 * This program is distributed in the hope that it will be useful, 13 * but WITHOUT ANY WARRANTY; without even the implied warranty of 14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the 15 * GNU General Public License for more details. 16 * 17 * You should have received a copy of the GNU General Public License 18 * along with this program. If not, see <https://www.gnu.org/licenses/>. 19 * 20 * @link https://www.librenms.org 21 * @copyright 2018 Tony Murray 22 * @author Tony Murray <murraytony@gmail.com> 23 */ 24 25namespace LibreNMS\Data\Store; 26 27use Illuminate\Support\Str; 28use LibreNMS\Config; 29use LibreNMS\Data\Measure\Measurement; 30use LibreNMS\Exceptions\FileExistsException; 31use LibreNMS\Exceptions\RrdGraphException; 32use LibreNMS\Proc; 33use LibreNMS\Util\Debug; 34use LibreNMS\Util\Rewrite; 35use Log; 36use Symfony\Component\Process\Process; 37 38class Rrd extends BaseDatastore 39{ 40 private $disabled = false; 41 42 /** @var Proc */ 43 private $sync_process; 44 /** @var Proc */ 45 private $async_process; 46 private $rrd_dir; 47 private $version; 48 private $rrdcached; 49 private $rra; 50 private $step; 51 52 public function __construct() 53 { 54 parent::__construct(); 55 $this->rrdcached = Config::get('rrdcached', false); 56 57 $this->init(); 58 $this->rrd_dir = Config::get('rrd_dir', Config::get('install_dir') . '/rrd'); 59 $this->step = Config::get('rrd.step', 300); 60 $this->rra = Config::get( 61 'rrd_rra', 62 'RRA:AVERAGE:0.5:1:2016 RRA:AVERAGE:0.5:6:1440 RRA:AVERAGE:0.5:24:1440 RRA:AVERAGE:0.5:288:1440 ' . 63 ' RRA:MIN:0.5:1:2016 RRA:MIN:0.5:6:1440 RRA:MIN:0.5:24:1440 RRA:MIN:0.5:288:1440 ' . 64 ' RRA:MAX:0.5:1:2016 RRA:MAX:0.5:6:1440 RRA:MAX:0.5:24:1440 RRA:MAX:0.5:288:1440 ' . 65 ' RRA:LAST:0.5:1:2016 ' 66 ); 67 $this->version = Config::get('rrdtool_version', '1.4'); 68 } 69 70 public function getName() 71 { 72 return 'RRD'; 73 } 74 75 public static function isEnabled() 76 { 77 return Config::get('rrd.enable', true); 78 } 79 80 /** 81 * Opens up a pipe to RRDTool using handles provided 82 * 83 * @param bool $dual_process start an additional process that's output should be read after every command 84 * @return bool the process(s) have been successfully started 85 */ 86 public function init($dual_process = true) 87 { 88 $command = Config::get('rrdtool', 'rrdtool') . ' -'; 89 90 $descriptor_spec = [ 91 0 => ['pipe', 'r'], // stdin is a pipe that the child will read from 92 1 => ['pipe', 'w'], // stdout is a pipe that the child will write to 93 2 => ['pipe', 'w'], // stderr is a pipe that the child will write to 94 ]; 95 96 $cwd = Config::get('rrd_dir'); 97 98 if (! $this->isSyncRunning()) { 99 $this->sync_process = new Proc($command, $descriptor_spec, $cwd); 100 } 101 102 if ($dual_process && ! $this->isAsyncRunning()) { 103 $this->async_process = new Proc($command, $descriptor_spec, $cwd); 104 $this->async_process->setSynchronous(false); 105 } 106 107 return $this->isSyncRunning() && ($dual_process ? $this->isAsyncRunning() : true); 108 } 109 110 public function isSyncRunning() 111 { 112 return isset($this->sync_process) && $this->sync_process->isRunning(); 113 } 114 115 public function isAsyncRunning() 116 { 117 return isset($this->async_process) && $this->async_process->isRunning(); 118 } 119 120 /** 121 * Close all open rrdtool processes. 122 * This should be done before exiting 123 */ 124 public function close() 125 { 126 if ($this->isSyncRunning()) { 127 $this->sync_process->close('quit'); 128 } 129 if ($this->isAsyncRunning()) { 130 $this->async_process->close('quit'); 131 } 132 } 133 134 /** 135 * rrdtool backend implementation of data_update 136 * 137 * Tags: 138 * rrd_def RrdDefinition 139 * rrd_name array|string: the rrd filename, will be processed with rrd_name() 140 * rrd_oldname array|string: old rrd filename to rename, will be processed with rrd_name() 141 * rrd_step int: rrd step, defaults to 300 142 * 143 * @param array $device device array 144 * @param string $measurement the name of this measurement (if no rrd_name tag is given, this will be used to name the file) 145 * @param array $tags tags to pass additional info to rrdtool 146 * @param array $fields data values to update 147 */ 148 public function put($device, $measurement, $tags, $fields) 149 { 150 $rrd_name = isset($tags['rrd_name']) ? $tags['rrd_name'] : $measurement; 151 $step = isset($tags['rrd_step']) ? $tags['rrd_step'] : $this->step; 152 if (! empty($tags['rrd_oldname'])) { 153 self::renameFile($device, $tags['rrd_oldname'], $rrd_name); 154 } 155 156 if (isset($tags['rrd_proxmox_name'])) { 157 $pmxvars = $tags['rrd_proxmox_name']; 158 $rrd = self::proxmoxName($pmxvars['pmxcluster'], $pmxvars['vmid'], $pmxvars['vmport']); 159 } else { 160 $rrd = self::name($device['hostname'], $rrd_name); 161 } 162 163 if (isset($tags['rrd_def'])) { 164 $rrd_def = $tags['rrd_def']; 165 166 // filter out data not in the definition 167 $fields = array_filter($fields, function ($key) use ($rrd_def) { 168 $valid = $rrd_def->isValidDataset($key); 169 if (! $valid) { 170 Log::warning("RRD warning: unused data sent $key"); 171 } 172 173 return $valid; 174 }, ARRAY_FILTER_USE_KEY); 175 176 if (! $this->checkRrdExists($rrd)) { 177 $newdef = "--step $step $rrd_def $this->rra"; 178 $this->command('create', $rrd, $newdef); 179 } 180 } 181 182 $this->update($rrd, $fields); 183 } 184 185 /** 186 * Updates an rrd database at $filename using $options 187 * Where $options is an array, each entry which is not a number is replaced with "U" 188 * 189 * @internal 190 * @param string $filename 191 * @param array $data 192 * @return array|string 193 */ 194 public function update($filename, $data) 195 { 196 $values = []; 197 // Do some sanitation on the data if passed as an array. 198 199 if (is_array($data)) { 200 $values[] = 'N'; 201 foreach ($data as $v) { 202 if (! is_numeric($v)) { 203 $v = 'U'; 204 } 205 206 $values[] = $v; 207 } 208 209 $data = implode(':', $values); 210 211 return $this->command('update', $filename, $data); 212 } else { 213 return 'Bad options passed to rrdtool_update'; 214 } 215 } 216 217 // rrdtool_update 218 219 /** 220 * Modify an rrd file's max value and trim the peaks as defined by rrdtool 221 * 222 * @param string $type only 'port' is supported at this time 223 * @param string $filename the path to the rrd file 224 * @param int $max the new max value 225 * @return bool 226 */ 227 public function tune($type, $filename, $max) 228 { 229 $fields = []; 230 if ($type === 'port') { 231 if ($max < 10000000) { 232 return false; 233 } 234 $max = $max / 8; 235 $fields = [ 236 'INOCTETS', 237 'OUTOCTETS', 238 'INERRORS', 239 'OUTERRORS', 240 'INUCASTPKTS', 241 'OUTUCASTPKTS', 242 'INNUCASTPKTS', 243 'OUTNUCASTPKTS', 244 'INDISCARDS', 245 'OUTDISCARDS', 246 'INUNKNOWNPROTOS', 247 'INBROADCASTPKTS', 248 'OUTBROADCASTPKTS', 249 'INMULTICASTPKTS', 250 'OUTMULTICASTPKTS', 251 ]; 252 } 253 if (count($fields) > 0) { 254 $options = '--maximum ' . implode(":$max --maximum ", $fields) . ":$max"; 255 $this->command('tune', $filename, $options); 256 } 257 258 return true; 259 } 260 261 // rrdtool_tune 262 263 /** 264 * Generates a filename for a proxmox cluster rrd 265 * 266 * @param string $pmxcluster 267 * @param string $vmid 268 * @param string $vmport 269 * @return string full path to the rrd. 270 */ 271 public function proxmoxName($pmxcluster, $vmid, $vmport) 272 { 273 $pmxcdir = join('/', [$this->rrd_dir, 'proxmox', self::safeName($pmxcluster)]); 274 // this is not needed for remote rrdcached 275 if (! is_dir($pmxcdir)) { 276 mkdir($pmxcdir, 0775, true); 277 } 278 279 return join('/', [$pmxcdir, self::safeName($vmid . '_netif_' . $vmport . '.rrd')]); 280 } 281 282 /** 283 * Get the name of the port rrd file. For alternate rrd, specify the suffix. 284 * 285 * @param int $port_id 286 * @param string $suffix 287 * @return string 288 */ 289 public function portName($port_id, $suffix = null) 290 { 291 return "port-id$port_id" . (empty($suffix) ? '' : '-' . $suffix); 292 } 293 294 /** 295 * rename an rrdfile, can only be done on the LibreNMS server hosting the rrd files 296 * 297 * @param array $device Device object 298 * @param string|array $oldname RRD name array as used with rrd_name() 299 * @param string|array $newname RRD name array as used with rrd_name() 300 * @return bool indicating rename success or failure 301 */ 302 public function renameFile($device, $oldname, $newname) 303 { 304 $oldrrd = self::name($device['hostname'], $oldname); 305 $newrrd = self::name($device['hostname'], $newname); 306 if (is_file($oldrrd) && ! is_file($newrrd)) { 307 if (rename($oldrrd, $newrrd)) { 308 log_event("Renamed $oldrrd to $newrrd", $device, 'poller', 1); 309 310 return true; 311 } else { 312 log_event("Failed to rename $oldrrd to $newrrd", $device, 'poller', 5); 313 314 return false; 315 } 316 } else { 317 // we don't need to rename the file 318 return true; 319 } 320 } 321 322 /** 323 * Generates a filename based on the hostname (or IP) and some extra items 324 * 325 * @param string $host Host name 326 * @param array|string $extra Components of RRD filename - will be separated with "-", or a pre-formed rrdname 327 * @param string $extension File extension (default is .rrd) 328 * @return string the name of the rrd file for $host's $extra component 329 */ 330 public function name($host, $extra, $extension = '.rrd') 331 { 332 $filename = self::safeName(is_array($extra) ? implode('-', $extra) : $extra); 333 334 return implode('/', [$this->dirFromHost($host), $filename . $extension]); 335 } 336 337 /** 338 * Generates a path based on the hostname (or IP) 339 * 340 * @param string $host Host name 341 * @return string the name of the rrd directory for $host 342 */ 343 public function dirFromHost($host) 344 { 345 $host = str_replace(':', '_', trim($host, '[]')); 346 347 return implode('/', [$this->rrd_dir, $host]); 348 } 349 350 /** 351 * Generates and pipes a command to rrdtool 352 * 353 * @internal 354 * @param string $command create, update, updatev, graph, graphv, dump, restore, fetch, tune, first, last, lastupdate, info, resize, xport, flushcached 355 * @param string $filename The full patth to the rrd file 356 * @param string $options rrdtool command options 357 * @return array the output of stdout and stderr in an array 358 * @throws \Exception thrown when the rrdtool process(s) cannot be started 359 */ 360 private function command($command, $filename, $options) 361 { 362 $stat = Measurement::start($this->coalesceStatisticType($command)); 363 $output = null; 364 365 try { 366 $cmd = self::buildCommand($command, $filename, $options); 367 } catch (FileExistsException $e) { 368 Log::debug("RRD[%g$filename already exists%n]", ['color' => true]); 369 370 return [null, null]; 371 } 372 373 Log::debug("RRD[%g$cmd%n]", ['color' => true]); 374 375 // do not write rrd files, but allow read-only commands 376 $ro_commands = ['graph', 'graphv', 'dump', 'fetch', 'first', 'last', 'lastupdate', 'info', 'xport']; 377 if ($this->disabled && ! in_array($command, $ro_commands)) { 378 if (! Config::get('hide_rrd_disabled')) { 379 Log::debug('[%rRRD Disabled%n]', ['color' => true]); 380 } 381 382 return [null, null]; 383 } 384 385 // send the command! 386 if (in_array($command, ['last', 'list']) && $this->init(false)) { 387 // send this to our synchronous process so output is guaranteed 388 $output = $this->sync_process->sendCommand($cmd); 389 } elseif ($this->init()) { 390 // don't care about the return of other commands, so send them to the faster async process 391 $output = $this->async_process->sendCommand($cmd); 392 } else { 393 Log::error('rrdtool could not start'); 394 } 395 396 if (Debug::isVerbose()) { 397 echo 'RRDtool Output: '; 398 echo $output[0]; 399 echo $output[1]; 400 } 401 402 $this->recordStatistic($stat->end()); 403 404 return $output; 405 } 406 407 /** 408 * Build a command for rrdtool 409 * Shortens the filename as needed 410 * Determines if --daemon and -O should be used 411 * 412 * @internal 413 * @param string $command The base rrdtool command. Usually create, update, last. 414 * @param string $filename The full path to the rrd file 415 * @param string $options Options for the command possibly including the rrd definition 416 * @return string returns a full command ready to be piped to rrdtool 417 * @throws FileExistsException if rrdtool <1.4.3 and the rrd file exists locally 418 */ 419 public function buildCommand($command, $filename, $options) 420 { 421 if ($command == 'create') { 422 // <1.4.3 doesn't support -O, so make sure the file doesn't exist 423 if (version_compare($this->version, '1.4.3', '<')) { 424 if (is_file($filename)) { 425 throw new FileExistsException(); 426 } 427 } else { 428 $options .= ' -O'; 429 } 430 } 431 432 // no remote for create < 1.5.5 and tune < 1.5 433 if ($this->rrdcached && 434 ! ($command == 'create' && version_compare($this->version, '1.5.5', '<')) && 435 ! ($command == 'tune' && $this->rrdcached && version_compare($this->version, '1.5', '<')) 436 ) { 437 // only relative paths if using rrdcached 438 $filename = str_replace([$this->rrd_dir . '/', $this->rrd_dir], '', $filename); 439 $options = str_replace([$this->rrd_dir . '/', $this->rrd_dir], '', $options); 440 441 return "$command $filename $options --daemon " . $this->rrdcached; 442 } 443 444 return "$command $filename $options"; 445 } 446 447 /** 448 * Get array of all rrd files for a device, 449 * via rrdached or localdisk. 450 * 451 * @param array $device device for which we get the rrd's 452 * @return array array of rrd files for this host 453 */ 454 public function getRrdFiles($device) 455 { 456 if ($this->rrdcached) { 457 $filename = sprintf('/%s', $device['hostname']); 458 $rrd_files = $this->command('list', $filename, ''); 459 // Command output is an array, create new array with each filename as a item in array. 460 $rrd_files_array = explode("\n", trim($rrd_files[0])); 461 // Remove status line from response 462 array_pop($rrd_files_array); 463 } else { 464 $rrddir = $this->dirFromHost($device['hostname']); 465 $pattern = sprintf('%s/*.rrd', $rrddir); 466 $rrd_files_array = glob($pattern); 467 } 468 469 sort($rrd_files_array); 470 471 return $rrd_files_array; 472 } 473 474 /** 475 * Get array of rrd files for specific application. 476 * 477 * @param array $device device for which we get the rrd's 478 * @param int $app_id application id on the device 479 * @param string $app_name name of app to be searched 480 * @param string $category which category of graphs are searched 481 * @return array array of rrd files for this host 482 */ 483 public function getRrdApplicationArrays($device, $app_id, $app_name, $category = null) 484 { 485 $entries = []; 486 $separator = '-'; 487 488 $rrdfile_array = $this->getRrdFiles($device); 489 if ($category) { 490 $pattern = sprintf('%s-%s-%s-%s', 'app', $app_name, $app_id, $category); 491 } else { 492 $pattern = sprintf('%s-%s-%s', 'app', $app_name, $app_id); 493 } 494 495 // app_name contains a separator character? consider it 496 $offset = substr_count($app_name, $separator); 497 498 foreach ($rrdfile_array as $rrd) { 499 if (str_contains($rrd, $pattern)) { 500 $filename = basename($rrd, '.rrd'); 501 $entry = explode($separator, $filename, 4 + $offset)[3 + $offset]; 502 if ($entry) { 503 array_push($entries, $entry); 504 } 505 } 506 } 507 508 return $entries; 509 } 510 511 /** 512 * Checks if the rrd file exists on the server 513 * This will perform a remote check if using rrdcached and rrdtool >= 1.5 514 * 515 * @param string $filename full path to the rrd file 516 * @return bool whether or not the passed rrd file exists 517 */ 518 public function checkRrdExists($filename) 519 { 520 if ($this->rrdcached && version_compare($this->version, '1.5', '>=')) { 521 $chk = $this->command('last', $filename, ''); 522 $filename = str_replace([$this->rrd_dir . '/', $this->rrd_dir], '', $filename); 523 524 return ! Str::contains(implode($chk), "$filename': No such file or directory"); 525 } else { 526 return is_file($filename); 527 } 528 } 529 530 /** 531 * Remove RRD file(s). Use with care as this permanently deletes rrd data. 532 * @param string $hostname rrd subfolder (hostname) 533 * @param string $prefix start of rrd file name all files matching will be deleted 534 */ 535 public function purge($hostname, $prefix) 536 { 537 if (empty($hostname)) { 538 Log::error("Could not purge rrd $prefix, empty hostname"); 539 540 return; 541 } 542 543 foreach (glob($this->name($hostname, $prefix, '*.rrd')) as $rrd) { 544 unlink($rrd); 545 } 546 } 547 548 /** 549 * Generates a graph file at $graph_file using $options 550 * Graphs are a single command per run, so this just runs rrdtool 551 * 552 * @param string $options 553 * @return string 554 * @throws \LibreNMS\Exceptions\FileExistsException 555 * @throws \LibreNMS\Exceptions\RrdGraphException 556 */ 557 public function graph(string $options): string 558 { 559 $process = new Process([Config::get('rrdtool', 'rrdtool'), '-'], $this->rrd_dir); 560 $process->setTimeout(300); 561 $process->setIdleTimeout(300); 562 563 $command = $this->buildCommand('graph', '-', $options); 564 $process->setInput($command . "\nquit"); 565 $process->run(); 566 567 $feedback_position = strrpos($process->getOutput(), 'OK '); 568 if ($feedback_position !== false) { 569 return substr($process->getOutput(), 0, $feedback_position); 570 } 571 572 // if valid image is returned with error, extract image and feedback 573 $image_type = Config::get('webui.graph_type', 'png'); 574 $search = $this->getImageEnd($image_type); 575 if (($position = strrpos($process->getOutput(), $search)) !== false) { 576 $position += strlen($search); 577 throw new RrdGraphException( 578 substr($process->getOutput(), $position), 579 $process->getExitCode(), 580 substr($process->getOutput(), 0, $position) 581 ); 582 } 583 584 // only error text was returned 585 $error = trim($process->getOutput() . PHP_EOL . $process->getErrorOutput()); 586 throw new RrdGraphException($error, $process->getExitCode(), ''); 587 } 588 589 private function getImageEnd(string $type): string 590 { 591 $image_suffixes = [ 592 'png' => hex2bin('0000000049454e44ae426082'), 593 'svg' => '</svg>', 594 ]; 595 596 return $image_suffixes[$type] ?? ''; 597 } 598 599 public function __destruct() 600 { 601 $this->close(); 602 } 603 604 /** 605 * Remove invalid characters from the rrd file name 606 * 607 * @param string $name 608 * @return string 609 */ 610 public static function safeName($name) 611 { 612 return (string) preg_replace('/[^a-zA-Z0-9,._\-]/', '_', $name); 613 } 614 615 /** 616 * Remove invalid characters from the rrd description 617 * 618 * @param string $descr 619 * @return string 620 */ 621 public static function safeDescr($descr) 622 { 623 return (string) preg_replace('/[^a-zA-Z0-9,._\-\/\ ]/', ' ', $descr); 624 } 625 626 /** 627 * Escapes strings and sets them to a fixed length for use with RRDtool 628 * 629 * @param string $descr the string to escape 630 * @param int $length if passed, string will be padded and trimmed to exactly this length (after rrdtool unescapes it) 631 * @return string 632 */ 633 public static function fixedSafeDescr($descr, $length) 634 { 635 $result = Rewrite::shortenIfType($descr); 636 $result = str_replace("'", '', $result); // remove quotes 637 638 if (is_numeric($length)) { 639 // preserve original $length for str_pad() 640 641 // determine correct strlen() for substr_count() 642 $substr_count_length = $length <= 0 ? null : min(strlen($descr), $length); 643 644 $extra = substr_count($descr, ':', 0, $substr_count_length); 645 $result = substr(str_pad($result, $length), 0, ($length + $extra)); 646 if ($extra > 0) { 647 $result = substr($result, 0, (-1 * $extra)); 648 } 649 } 650 651 $result = str_replace(':', '\:', $result); // escape colons 652 653 return $result . ' '; 654 } 655 656 /** 657 * Only track update and create primarily, just put all others in an "other" bin 658 * 659 * @param string $type 660 * @return string 661 */ 662 private function coalesceStatisticType($type) 663 { 664 return ($type == 'update' || $type == 'create') ? $type : 'other'; 665 } 666} 667