#!/usr/bin/env perl #Copyright (c) 2023, Zane C. Bowers-Hadley #All rights reserved. # #Redistribution and use in source and binary forms, with or without modification, #are permitted provided that the following conditions are met: # # * Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # #THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND #ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED #WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. #IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, #INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, #BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, #DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF #LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR #OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF #THE POSSIBILITY OF SUCH DAMAGE. =head1 NAME borgbackup - LibreNMS JSON SNMP extend for gathering backups for borg =head1 VERSION 0.0.1 =head1 SYNOPSIS borgbackup [B<-c> ] [B<-o> ] borgbackup [B<--help>|B<-h>] borgbackup [B<--version>|B<-v>] =head1 DESCRIPTION This uses 'borg info $repo --json' to fetch info on the specified borg repos and write the info out to files. The information is then writen out two two files under the output directory. - extend_return :: This file contains the data for the extend in gzip+base64 compressed format if applicable. - pretty :: Pretty printed and sorted JSON. This is done for three reasons. The first is SNMPD and the users with read perms for the repos are likely to be different. The second is lock time out, even with 1 second, means the command likely won't complete in a timely manner for larger repos. For SNMPD generally going to be setup like this. extend borgbackup /bin/cat /var/cache/borgbackup_extend Then the extend is set to be ran via cron. */5 * * * * /etc/snmp/extends/borgbackup =head1 FLAGS =head2 -c The config file to use for the extend. Default :: /usr/local/etc/borgbackup_extend.ini =head2 -o The output directory write the pretty JSON file to and the file to use for the SNMP extend. Default :: /var/cache/borgbackup_extend =head2 -h|--help Print help info. =head2 -v|--version Print version info. =head1 CONFIG The config file is a ini file and handled by L. - mode :: single or multi, for if this is a single repo or for multiple repos. - Default :: single - repo :: Directory for the borg backup repo. - Default :: undef - passphrase :: Passphrase for the borg backup repo. - Default :: undef - passcommand :: Passcommand for the borg backup repo. - Default :: undef For single repos all those variables are in the root section of the config, so lets the repo is at '/backup/borg' with a passphrase of '1234abc'. repo=/backup/borg repo=1234abc For multi, each section outside of the root represents a repo. So if there is '/backup/borg1' with a passphrase of 'foobar' and '/backup/derp' with a passcommand of 'pass show backup' it would be like below. mode=multi [borg1] repo=/backup/borg1 passphrase=foobar [derp] repo=/backup/derp passcommand=pass show backup If 'passphrase' and 'passcommand' are both specified, then passcommand is used. =head1 JSON RETURN The return is a LibreNMS JSON style SNMP extend as defined at L The following key info is relevant to the .data . - .mode :: The mode it was ran in, either single or multi. Totaled info is in the hash .totals. - .totals.errored :: Total number of repos that info could not be fetched for. - Type :: repos - .totals.locked :: Total number of locked repos - Type :: repos - .totals.locked_for :: Longest time any repo has been locked. - Type :: seconds - .totals.time_since_last_modified :: Largest time - mtime for the repo directory - Type :: seconds - .total.total_chunks :: Total number of checks between all repos. - Type :: chunks - .total.total_csize :: Total compressed size of all archives in all repos. - Type :: bytes - .total.total_size :: Total uncompressed size of all archives in all repos. - Type :: bytes - .total.total_unique_chunks :: Total number of unique chuckes in all repos. - Type :: chunks - .total.unique_csize :: Total deduplicated size of all archives in all repos. - Type :: bytes - .total.unique_size :: Total number of chunks in all repos. - Type :: chunks Each repo then has it's own hash under .repo . - .repo.$repo.error :: If defined, this is the error encounted when attempting to get repo info. - Type :: string - .repo.$repo.locked_for :: How long the repo has been locked for if locked. If it is not locked this is undef. - Type :: seconds - .repo.$repo.time_since_last_modified :: time - mtime for the repo directory - Type :: seconds - .repo.$repo.total_chunks :: Total number of checks for the repo. - Type :: chunks - .repo.$repo.total_csize :: Total compressed size of all archives for the repo. - Type :: bytes - .repo.$repo.total_size :: Total uncompressed size of all archives the repo. - Type :: bytes - .repo.$repo.total_unique_chunks :: Total number of unique chuckes the repo. - Type :: chunks - .repo.$repo.unique_csize :: Total deduplicated size of all archives the repo. - Type :: bytes - .repo.$repo.unique_size :: Total number of chunks in the repo. - Type :: chunks =cut use strict; use warnings; use Config::Tiny; use JSON; use Getopt::Long; use File::Slurp; use File::Path qw(make_path); use MIME::Base64; use IO::Compress::Gzip qw(gzip $GzipError); use String::ShellQuote; use Pod::Usage; our $output_dir = '/var/cache/borgbackup_extend'; my $config_file = '/usr/local/etc/borgbackup_extend.ini'; my $version; my $help; GetOptions( 'c=s' => \$config_file, 'o=s' => \$output_dir, v => \$version, version => \$version, h => \$help, help => \$help, ); if ($version) { pod2usage( -exitval => 255, -verbose => 99, -sections => qw(VERSION), -output => \*STDOUT, ); } if ($help) { pod2usage( -exitval => 255, -verbose => 2, -output => \*STDOUT, ); } # save the return sub finish { my (%opts) = @_; if ( !-e $output_dir ) { make_path($output_dir) or die( 'could not create the output dir, "' . $output_dir . '",' ); } elsif ( -e $output_dir && !-d $output_dir ) { die( '"' . $output_dir . '" exists, but is not a directory' ); } my $j = JSON->new; my $return_string = $j->encode( $opts{to_return} ); my $compressed_string; gzip \$return_string => \$compressed_string; my $compressed = encode_base64($compressed_string); $compressed =~ s/\n//g; $compressed = $compressed . "\n"; if ( length($compressed) > length($return_string) ) { write_file( $output_dir . '/extend_return', $return_string ); } else { write_file( $output_dir . '/extend_return', $compressed ); } $j->pretty(1); $j->canonical(1); $return_string = $j->encode( $opts{to_return} ); write_file( $output_dir . '/pretty', $return_string ); print $return_string; exit $opts{to_return}->{error}; } ## end sub finish my $to_return = { data => { mode => 'single', totals => { total_chunks => 0, total_csize => 0, total_size => 0, total_unique_chunks => 0, unique_csize => 0, unique_size => 0, locked => 0, time_since_last_modified => undef, errored => 0, locked_for => undef, }, repos => {}, }, version => 1, error => 0, errorString => '', }; # attempt to read in the config my $config; eval { my $raw_config = read_file($config_file); ($config) = Config::Tiny->read_string($raw_config); }; if ($@) { $to_return->{error} = 1; $to_return->{errorString} = 'Failed reading config file "' . $config_file . '"... ' . $@; finish( to_return => $to_return ); } if ( !defined( $config->{_}{mode} ) ) { $config->{_}{mode} = 'single'; } elsif ( $config->{_}{mode} ne 'single' && $config->{_}{mode} ne 'multi' ) { $to_return->{error} = 2; $to_return->{errorString} = '"' . $config->{_}{mode} . '" mode is not set to single or multi'; finish( to_return => $to_return ); } # get a list of repos to use my @repos; if ( $config->{_}{mode} eq 'single' ) { # if single, just create a single repo push( @repos, 'single' ); $config->{single} = {}; # make sure we have passcommand or passphrase with passphrase being used as the default if ( !defined( $config->{_}{passcommand} ) && !defined( $config->{_}{passphrase} ) ) { $to_return->{error} = 3; $to_return->{errorString} = 'Neither passcommand or passphrase defined'; finish( to_return => $to_return ); } elsif ( $config->{_}{passphrase} ) { $config->{single}{passphrase} = $config->{_}{passphrase}; } elsif ( $config->{_}{passcommand} ) { $config->{single}{passcommand} = $config->{_}{passcommand}; } # make sure have a repo specified if ( !defined( $config->{_}{repo} ) ) { $to_return->{error} = 4; $to_return->{errorString} = 'repo is not defined'; finish( to_return => $to_return ); } $config->{single}{repo} = $config->{_}{repo}; } else { # we don't want _ as that is the root of the ini file @repos = grep( !/^\_$/, keys( %{$config} ) ); } my @totals = ( 'total_chunks', 'total_csize', 'total_size', 'total_unique_chunks', 'unique_csize', 'unique_size', 'locked' ); my @stats = ( 'total_chunks', 'total_csize', 'total_size', 'total_unique_chunks', 'unique_csize', 'unique_size' ); foreach my $repo (@repos) { my $process = 1; # unset borg pass bits delete( $ENV{BORG_PASSPHRASE} ); delete( $ENV{BORG_PASSCOMMAND} ); my $repo_info = { total_chunks => 0, total_csize => 0, total_size => 0, total_unique_chunks => 0, unique_csize => 0, unique_size => 0, locked => 0, time_since_last_modified => undef, error => undef, locked_for => undef, }; if ( !defined( $config->{$repo}{passcommand} ) && !defined( $config->{$repo}{passphrase} ) ) { $to_return->{error} = 3; $to_return->{errorString} = $to_return->{errorString} . "\n" . 'Neither passcommand or passphrase defined for ' . $repo; $process = 0; } if ( !defined( $config->{$repo}{repo} ) ) { $to_return->{error} = 4; $to_return->{errorString} = $to_return->{errorString} . "\n" . 'repo is not defined for ' . $repo; $process = 0; } if ($process) { my ( $dev, $ino, $mode, $nlink, $uid, $gid, $rdev, $size, $atime, $mtime, $ctime, $blksize, $blocks ) = stat( $config->{$repo}{repo} . '/nonce' ); my $time_diff = time - $mtime; $repo_info->{time_since_last_modified} = $time_diff; # if we don't have a largest time diff or if it is larger than then # the old one save the time diff if ( !defined( $to_return->{data}{totals}{time_since_last_modified} ) || $to_return->{data}{totals}{time_since_last_modified} < $time_diff ) { $to_return->{data}{totals}{time_since_last_modified} = $time_diff; } if ( defined( $config->{$repo}{passcommand} ) ) { $ENV{BORG_PASSCOMMAND} = $config->{$repo}{passcommand}; } else { $ENV{BORG_PASSPHRASE} = $config->{$repo}{passphrase}; } my $command = 'borg info ' . shell_quote( $config->{$repo}{repo} ) . ' --json 2>&1'; my $output_raw = `$command`; my $info; eval { $info = decode_json($output_raw); }; if ($@) { my $error = $@; if ( $output_raw =~ /lock.*lock\.exclusive/ ) { $repo_info->{locked} = 1; my $lock_file = $config->{$repo}{repo} . '/lock.exclusive'; ( $dev, $ino, $mode, $nlink, $uid, $gid, $rdev, $size, $atime, $mtime, $ctime, $blksize, $blocks ) = stat($lock_file); $repo_info->{locked_for} = time - $ctime; } else { $repo_info->{error} = $error; } } else { if ( defined( $info->{cache} ) && defined( $info->{cache}{stats} ) ) { for my $stat (@stats) { $repo_info->{$stat} = $info->{cache}{stats}{$stat}; } } } for my $total (@totals) { $to_return->{data}{totals}{$total} = $to_return->{data}{totals}{$total} + $repo_info->{$total}; } if ( defined( $repo_info->{error} ) ) { $to_return->{data}{totals}{errored}++; } if ( !defined( $to_return->{data}{totals}{locked_for} ) || $to_return->{data}{totals}{locked_for} < $repo_info->{locked_for} ) { $to_return->{data}{totals}{locked_for} = $repo_info->{locked_for}; } } ## end if ($process) $to_return->{data}{repos}{$repo} = $repo_info; } ## end foreach my $repo (@repos) finish( to_return => $to_return );