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