From baa055c719817af124e88ccab79541a3402a5464 Mon Sep 17 00:00:00 2001 From: Torstein EIde <1884894+Torstein-Eide@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:50:55 +0100 Subject: [PATCH 01/10] Improve borgbackup to match smart-v1 style - Add -u update mode for cron-based cache generation - Add -p for pretty print JSON output - Add -Z for GZip+Base64 compression of cache file - Add -v for verbose output to STDERR - Add -V and -h with improved inline help messages - Fix mtime detection to fallback to repo directory if nonce file missing - Fix mode output to match config setting - Fix verbose output to go to STDERR instead of STDOUT - Add verbose config parsing output - Add verbose output for file writes - Only compress cache file when -Z is used, not STDOUT output --- snmp/borgbackup | 258 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 212 insertions(+), 46 deletions(-) diff --git a/snmp/borgbackup b/snmp/borgbackup index c6c88fb11..d0216b2da 100755 --- a/snmp/borgbackup +++ b/snmp/borgbackup @@ -1,6 +1,5 @@ #!/usr/bin/env perl - -#Copyright (c) 2023, Zane C. Bowers-Hadley +#Copyright (c) 2026, Zane C. Bowers-Hadley #All rights reserved. # #Redistribution and use in source and binary forms, with or without modification, @@ -29,15 +28,11 @@ borgbackup - LibreNMS JSON SNMP extend for gathering backups for borg =head1 VERSION -0.0.1 +0.1.0 =head1 SYNOPSIS -borgbackup [B<-c> ] [B<-o> ] - -borgbackup [B<--help>|B<-h>] - -borgbackup [B<--version>|B<-v>] +borgbackup [-c ] [-o ] [-u] [-p] [-Z] [-v] [-V] [-h] =head1 DESCRIPTION @@ -62,7 +57,14 @@ For SNMPD generally going to be setup like this. Then the extend is set to be ran via cron. - */5 * * * * /etc/snmp/extends/borgbackup + */5 * * * * /etc/snmp/extends/borgbackup -u -Z + +=head1 USAGE + +When called without flags, it reads from the cache file if it exists. +Use -u to update the cache, or -u -v for verbose updates. + + /etc/snmp/extends/borgbackup -u -v =head1 FLAGS @@ -79,13 +81,30 @@ for the SNMP extend. Default :: /var/cache/borgbackup_extend -=head2 -h|--help +=head2 -u -Print help info. +Update mode. When called from snmpd, it reads from the cache file instead +of running borg commands directly. + +=head2 -p + +Pretty print the JSON output. -=head2 -v|--version +=head2 -Z -Print version info. +GZip+Base64 compress the results. + +=head2 -v + +Verbose output. Print status info to STDERR. + +=head2 -V + +Print version and exit. + +=head2 -h + +Print help info. =head1 CONFIG @@ -204,7 +223,7 @@ use strict; use warnings; use Config::Tiny; use JSON; -use Getopt::Long; +use Getopt::Long qw( GetOptions :config no_ignore_case ); use File::Slurp; use File::Path qw(make_path); use MIME::Base64; @@ -214,26 +233,62 @@ use Pod::Usage; our $output_dir = '/var/cache/borgbackup_extend'; my $config_file = '/usr/local/etc/borgbackup_extend.ini'; -my $version; -my $help; +my $verbose = 0; +my $pretty = 0; +my $compress = 0; +my $update = 0; + +sub VERSION_MESSAGE { + print "borgbackup SNMP extend 0.1.0\n"; +} + +sub HELP_MESSAGE { + print "borgbackup SNMP extend 0.1.0\n\n"; + print "Usage: borgbackup [options]\n\n"; + print "Options:\n"; + print " -c Config file (default: $config_file)\n"; + print " -o Output directory (default: $output_dir)\n"; + print " -u Update mode - regenerate cache from borg repos\n"; + print " -p Pretty print JSON output\n"; + print " -Z GZip+Base64 compress the cache file\n"; + print " -v Verbose output to STDERR\n"; + print " -V Print version and exit\n"; + print " -h Print this help message\n"; + print "\n"; + print "When called without -u, reads from cache file for SNMP extend.\n"; + print "\n"; + print "Typical usage:\n"; + print " SNMPD: extend borgbackup /bin/cat /var/cache/borgbackup_extend/extend_return\n"; + print " Cron: */5 * * * * /usr/local/lib/snmpd/borgbackup -u -Z\n"; + print "\n"; + print "Config file (INI format):\n"; + print " mode=multi # single or multi\n"; + print " repo=/path/to/repo # for single mode\n"; + print " passphrase=secret # or use passcommand\n"; + print "\n"; + print " [repo1] # for multi mode\n"; + print " repo=/path/to/repo1\n"; + print " passphrase=secret1\n"; + print "\n"; + print " [repo2]\n"; + print " repo=/path/to/repo2\n"; + print " passcommand=pass show repo2\n"; +} + 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, ); + 'v' => \$verbose, + 'p' => \$pretty, + 'Z' => \$compress, + 'u' => \$update, + 'V' => sub { VERSION_MESSAGE(); exit 0; }, + 'h' => sub { HELP_MESSAGE(); exit 0; }, +) or die "Error in command line arguments\n"; +if ($verbose) { + warn "DEBUG: verbose=$verbose, update=$update, pretty=$pretty, compress=$compress\n"; } -if ($help) { - pod2usage( -exitval => 255, -verbose => 2, -output => \*STDOUT, ); -} - -# save the return sub finish { my (%opts) = @_; @@ -244,24 +299,40 @@ sub finish { } my $j = JSON->new; + if ($pretty) { + $j->pretty(1); + $j->canonical(1); + } 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 ); + if ($compress) { + 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) ) { + if ($verbose) { + print STDERR "Writing uncompressed extend_return to: $output_dir/extend_return\n"; + } + write_file( $output_dir . '/extend_return', $return_string ); + } else { + if ($verbose) { + print STDERR "Writing compressed extend_return to: $output_dir/extend_return\n"; + } + write_file( $output_dir . '/extend_return', $compressed ); + } } else { - write_file( $output_dir . '/extend_return', $compressed ); + if ($verbose) { + print STDERR "Writing extend_return to: $output_dir/extend_return\n"; + } + write_file( $output_dir . '/extend_return', $return_string ); } - $j->pretty(1); - $j->canonical(1); - $return_string = $j->encode( $opts{to_return} ); - + if ($verbose) { + print STDERR "Writing pretty JSON to: $output_dir/pretty\n"; + } write_file( $output_dir . '/pretty', $return_string ); print $return_string; @@ -291,6 +362,24 @@ my $to_return = { errorString => '', }; +if ( !$update && !$pretty ) { + my $cache_extra = ''; + if ($compress) { + $cache_extra = '.snmp'; + } + if ( -f $output_dir . '/extend_return' . $cache_extra ) { + my $old = read_file( $output_dir . '/extend_return' . $cache_extra ); + print $old; + exit 0; + } else { + $update = 1; + } +} + +if ($verbose) { + print STDERR "Processing repos in mode: update\n"; +} + # attempt to read in the config my $config; eval { @@ -303,20 +392,35 @@ if ($@) { finish( to_return => $to_return ); } +if ($verbose) { + print STDERR "Config file: $config_file\n"; +} + if ( !defined( $config->{_}{mode} ) ) { $config->{_}{mode} = 'single'; + if ($verbose) { + print STDERR "Config: mode not set, defaulting to 'single'\n"; + } } 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 ); } +$to_return->{data}{mode} = $config->{_}{mode}; +if ($verbose) { + print STDERR "Config: mode set to '" . $config->{_}{mode} . "'\n"; +} + # 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} = {}; + if ($verbose) { + print STDERR "Config: single mode, using root section config\n"; + } # make sure we have passcommand or passphrase with passphrase being used as the default if ( !defined( $config->{_}{passcommand} ) && !defined( $config->{_}{passphrase} ) ) { @@ -325,8 +429,14 @@ if ( $config->{_}{mode} eq 'single' ) { finish( to_return => $to_return ); } elsif ( $config->{_}{passphrase} ) { $config->{single}{passphrase} = $config->{_}{passphrase}; + if ($verbose) { + print STDERR "Config: using passphrase from root section\n"; + } } elsif ( $config->{_}{passcommand} ) { $config->{single}{passcommand} = $config->{_}{passcommand}; + if ($verbose) { + print STDERR "Config: using passcommand from root section\n"; + } } # make sure have a repo specified @@ -336,10 +446,16 @@ if ( $config->{_}{mode} eq 'single' ) { finish( to_return => $to_return ); } $config->{single}{repo} = $config->{_}{repo}; + if ($verbose) { + print STDERR "Config: repo path: " . $config->{_}{repo} . "\n"; + } } else { # we don't want _ as that is the root of the ini file @repos = grep( !/^\_$/, keys( %{$config} ) ); + if ($verbose) { + print STDERR "Config: multi mode, found " . scalar(@repos) . " repos: " . join(', ', @repos) . "\n"; + } } my @totals @@ -347,6 +463,10 @@ my @totals my @stats = ( 'total_chunks', 'total_csize', 'total_size', 'total_unique_chunks', 'unique_csize', 'unique_size' ); foreach my $repo (@repos) { + if ($verbose) { + print STDERR "Processing repo: $repo\n"; + } + my $process = 1; # unset borg pass bits @@ -371,20 +491,44 @@ foreach my $repo (@repos) { $to_return->{errorString} = $to_return->{errorString} . "\n" . 'Neither passcommand or passphrase defined for ' . $repo; $process = 0; + if ($verbose) { + print STDERR " ERROR: No passphrase or passcommand for $repo\n"; + } } 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 ($verbose) { + print STDERR " ERROR: No repo path defined for $repo\n"; + } } if ($process) { - my ( $dev, $ino, $mode, $nlink, $uid, $gid, $rdev, $size, $atime, $mtime, $ctime, $blksize, $blocks ) - = stat( $config->{$repo}{repo} . '/nonce' ); + if ($verbose) { + print STDERR " Repo path: " . $config->{$repo}{repo} . "\n"; + } + + my $nonce_file = $config->{$repo}{repo} . '/nonce'; + my $mtime; + if ( -e $nonce_file ) { + if ($verbose) { + print STDERR " Checking nonce file mtime: $nonce_file\n"; + } + ( undef, undef, undef, undef, undef, undef, undef, undef, undef, $mtime, undef, undef, undef ) = stat($nonce_file); + } else { + if ($verbose) { + print STDERR " No nonce file found, using repo directory mtime: " . $config->{$repo}{repo} . "\n"; + } + ( undef, undef, undef, undef, undef, undef, undef, undef, undef, $mtime, undef, undef, undef ) = stat( $config->{$repo}{repo} ); + } my $time_diff = time - $mtime; $repo_info->{time_since_last_modified} = $time_diff; + if ($verbose) { + print STDERR " Time since last modified: $time_diff seconds\n"; + } # if we don't have a largest time diff or if it is larger than then # the old one save the time diff @@ -401,6 +545,9 @@ foreach my $repo (@repos) { } my $command = 'borg info ' . shell_quote( $config->{$repo}{repo} ) . ' --json 2>&1'; + if ($verbose) { + print STDERR " Command: $command\n"; + } my $output_raw = `$command`; my $info; @@ -411,17 +558,28 @@ foreach my $repo (@repos) { $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; + if ($verbose) { + print STDERR " Repo is locked, checking: $lock_file\n"; + } + my ( $lock_dev, $lock_ino, $lock_mode, $lock_nlink, $lock_uid, $lock_gid, $lock_rdev, $lock_size, $lock_atime, $lock_mtime, $lock_ctime, $lock_blksize, $lock_blocks ) = stat($lock_file); + $repo_info->{locked_for} = time - $lock_ctime; + if ($verbose) { + print STDERR " Repo is locked for " . $repo_info->{locked_for} . " seconds\n"; + } } else { $repo_info->{error} = $error; + if ($verbose) { + print STDERR " ERROR: " . $error . "\n"; + } } } else { if ( defined( $info->{cache} ) && defined( $info->{cache}{stats} ) ) { for my $stat (@stats) { $repo_info->{$stat} = $info->{cache}{stats}{$stat}; } + if ($verbose) { + print STDERR " Stats: total_size=" . $repo_info->{total_size} . ", total_csize=" . $repo_info->{total_csize} . "\n"; + } } } @@ -443,4 +601,12 @@ foreach my $repo (@repos) { $to_return->{data}{repos}{$repo} = $repo_info; } ## end foreach my $repo (@repos) +if ($verbose) { + print STDERR "Totals:\n"; + print STDERR " errored: " . $to_return->{data}{totals}{errored} . "\n"; + print STDERR " locked: " . $to_return->{data}{totals}{locked} . "\n"; + print STDERR " total_size: " . $to_return->{data}{totals}{total_size} . "\n"; + print STDERR " total_csize: " . $to_return->{data}{totals}{total_csize} . "\n"; +} + finish( to_return => $to_return ); From ed37fd22723a1309f73423158980e6c84355bec3 Mon Sep 17 00:00:00 2001 From: Torstein EIde <1884894+Torstein-Eide@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:58:13 +0100 Subject: [PATCH 02/10] Remove JSON from STDOUT, add simple progress to STDOUT --- snmp/borgbackup | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/snmp/borgbackup b/snmp/borgbackup index d0216b2da..328b39825 100755 --- a/snmp/borgbackup +++ b/snmp/borgbackup @@ -335,8 +335,6 @@ sub finish { } write_file( $output_dir . '/pretty', $return_string ); - print $return_string; - exit $opts{to_return}->{error}; } ## end sub finish @@ -462,7 +460,10 @@ 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' ); +print "Processing " . scalar(@repos) . " repos...\n"; + foreach my $repo (@repos) { + print "Processing repo $repo\n"; if ($verbose) { print STDERR "Processing repo: $repo\n"; } From 34fbb110294ccdd115467a8cbc28c674b8088200 Mon Sep 17 00:00:00 2001 From: Torstein EIde <1884894+Torstein-Eide@users.noreply.github.com> Date: Wed, 18 Mar 2026 19:35:35 +0100 Subject: [PATCH 03/10] Add timeout config, error if -Z without -u, remove redundant verbose output, add runtime tracking --- snmp/borgbackup | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/snmp/borgbackup b/snmp/borgbackup index 328b39825..4664d82f7 100755 --- a/snmp/borgbackup +++ b/snmp/borgbackup @@ -123,6 +123,10 @@ The config file is a ini file and handled by L. - passcommand :: Passcommand for the borg backup repo. - Default :: undef + - timeout :: Timeout in seconds for borg info command per repo. + Use 0 to disable timeout. + - Default :: 300 + 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'. @@ -237,6 +241,7 @@ my $verbose = 0; my $pretty = 0; my $compress = 0; my $update = 0; +my $default_timeout = 300; sub VERSION_MESSAGE { print "borgbackup SNMP extend 0.1.0\n"; @@ -264,11 +269,13 @@ sub HELP_MESSAGE { print "Config file (INI format):\n"; print " mode=multi # single or multi\n"; print " repo=/path/to/repo # for single mode\n"; - print " passphrase=secret # or use passcommand\n"; + print " passphrase=secret # or use passcommand\n"; + print " timeout=300 # seconds (0=disable)\n"; print "\n"; print " [repo1] # for multi mode\n"; print " repo=/path/to/repo1\n"; print " passphrase=secret1\n"; + print " timeout=600 # per-repo override\n"; print "\n"; print " [repo2]\n"; print " repo=/path/to/repo2\n"; @@ -285,6 +292,9 @@ GetOptions( 'V' => sub { VERSION_MESSAGE(); exit 0; }, 'h' => sub { HELP_MESSAGE(); exit 0; }, ) or die "Error in command line arguments\n"; +if ($compress && !$update) { + die "-Z requires -u (update mode) for compression\n"; +} if ($verbose) { warn "DEBUG: verbose=$verbose, update=$update, pretty=$pretty, compress=$compress\n"; } @@ -370,7 +380,7 @@ if ( !$update && !$pretty ) { print $old; exit 0; } else { - $update = 1; + die "Cache file not found. Run with -u to update cache.\n"; } } @@ -464,9 +474,6 @@ print "Processing " . scalar(@repos) . " repos...\n"; foreach my $repo (@repos) { print "Processing repo $repo\n"; - if ($verbose) { - print STDERR "Processing repo: $repo\n"; - } my $process = 1; @@ -545,11 +552,20 @@ foreach my $repo (@repos) { $ENV{BORG_PASSPHRASE} = $config->{$repo}{passphrase}; } - my $command = 'borg info ' . shell_quote( $config->{$repo}{repo} ) . ' --json 2>&1'; + my $timeout = $config->{$repo}{timeout} || $default_timeout; + my $command = 'borg info ' . shell_quote( $config->{$repo}{repo} ) . ' --json 2>&1'; + if ($timeout > 0) { + $command = 'timeout ' . $timeout . ' ' . $command; + } if ($verbose) { print STDERR " Command: $command\n"; } + my $start_time = time; my $output_raw = `$command`; + my $elapsed = time - $start_time; + if ($verbose) { + print STDERR " Runtime: $elapsed seconds\n"; + } my $info; eval { $info = decode_json($output_raw); }; From 37169ad82fd68c5c84c313b10e27224f7da5b2c2 Mon Sep 17 00:00:00 2001 From: Torstein EIde <1884894+Torstein-Eide@users.noreply.github.com> Date: Wed, 18 Mar 2026 19:52:29 +0100 Subject: [PATCH 04/10] Remove required passphrase/passcommand, add cache_dir config, add verbose auth/timeout output --- snmp/borgbackup | 108 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 75 insertions(+), 33 deletions(-) diff --git a/snmp/borgbackup b/snmp/borgbackup index 4664d82f7..fe18d61c2 100755 --- a/snmp/borgbackup +++ b/snmp/borgbackup @@ -118,15 +118,20 @@ The config file is a ini file and handled by L. - Default :: undef - passphrase :: Passphrase for the borg backup repo. + If not set, will try without passphrase. - Default :: undef - passcommand :: Passcommand for the borg backup repo. + If not set, will try without passcommand. - Default :: undef - timeout :: Timeout in seconds for borg info command per repo. Use 0 to disable timeout. - Default :: 300 + - cache_dir :: BORG_CACHE_DIR environment variable for borg cache location. + - 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'. @@ -271,6 +276,7 @@ sub HELP_MESSAGE { print " repo=/path/to/repo # for single mode\n"; print " passphrase=secret # or use passcommand\n"; print " timeout=300 # seconds (0=disable)\n"; + print " cache_dir=/path/to/cache # BORG_CACHE_DIR\n"; print "\n"; print " [repo1] # for multi mode\n"; print " repo=/path/to/repo1\n"; @@ -280,6 +286,7 @@ sub HELP_MESSAGE { print " [repo2]\n"; print " repo=/path/to/repo2\n"; print " passcommand=pass show repo2\n"; + print " cache_dir=/tmp/borg-cache\n"; } GetOptions( @@ -430,21 +437,29 @@ if ( $config->{_}{mode} eq 'single' ) { print STDERR "Config: single mode, using root section config\n"; } - # 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} ) { + # store passphrase/passcommand if provided + if ( defined( $config->{_}{passphrase} ) ) { $config->{single}{passphrase} = $config->{_}{passphrase}; if ($verbose) { print STDERR "Config: using passphrase from root section\n"; } - } elsif ( $config->{_}{passcommand} ) { + } elsif ( defined( $config->{_}{passcommand} ) ) { $config->{single}{passcommand} = $config->{_}{passcommand}; if ($verbose) { print STDERR "Config: using passcommand from root section\n"; } + } else { + if ($verbose) { + print STDERR "Config: no passphrase/passcommand, will try without\n"; + } + } + + # store cache_dir if provided + if ( defined( $config->{_}{cache_dir} ) ) { + $config->{single}{cache_dir} = $config->{_}{cache_dir}; + if ($verbose) { + print STDERR "Config: using cache_dir from root section\n"; + } } # make sure have a repo specified @@ -477,9 +492,10 @@ foreach my $repo (@repos) { my $process = 1; - # unset borg pass bits + # unset borg env bits delete( $ENV{BORG_PASSPHRASE} ); delete( $ENV{BORG_PASSCOMMAND} ); + delete( $ENV{BORG_CACHE_DIR} ); my $repo_info = { total_chunks => 0, @@ -494,16 +510,6 @@ foreach my $repo (@repos) { 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 ($verbose) { - print STDERR " ERROR: No passphrase or passcommand for $repo\n"; - } - } - if ( !defined( $config->{$repo}{repo} ) ) { $to_return->{error} = 4; $to_return->{errorString} = $to_return->{errorString} . "\n" . 'repo is not defined for ' . $repo; @@ -548,11 +554,31 @@ foreach my $repo (@repos) { if ( defined( $config->{$repo}{passcommand} ) ) { $ENV{BORG_PASSCOMMAND} = $config->{$repo}{passcommand}; - } else { + if ($verbose) { + print STDERR " Auth: using passcommand\n"; + } + } elsif ( defined( $config->{$repo}{passphrase} ) ) { $ENV{BORG_PASSPHRASE} = $config->{$repo}{passphrase}; + if ($verbose) { + print STDERR " Auth: using passphrase\n"; + } + } else { + if ($verbose) { + print STDERR " Auth: none configured, trying without\n"; + } + } + + if ( defined( $config->{$repo}{cache_dir} ) ) { + $ENV{BORG_CACHE_DIR} = $config->{$repo}{cache_dir}; + if ($verbose) { + print STDERR " Cache dir: $ENV{BORG_CACHE_DIR}\n"; + } } my $timeout = $config->{$repo}{timeout} || $default_timeout; + if ($verbose) { + print STDERR " Timeout: " . ($timeout > 0 ? $timeout . " seconds" : "disabled") . "\n"; + } my $command = 'borg info ' . shell_quote( $config->{$repo}{repo} ) . ' --json 2>&1'; if ($timeout > 0) { $command = 'timeout ' . $timeout . ' ' . $command; @@ -571,22 +597,38 @@ foreach my $repo (@repos) { 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'; + my $tried_without_pass = 0; + if ( !defined( $config->{$repo}{passcommand} ) && !defined( $config->{$repo}{passphrase} ) ) { + delete $ENV{BORG_PASSPHRASE}; + delete $ENV{BORG_PASSCOMMAND}; + $output_raw = `$command`; + $elapsed = time - $start_time; if ($verbose) { - print STDERR " Repo is locked, checking: $lock_file\n"; + print STDERR " No passphrase configured, retrying without...\n"; + print STDERR " Runtime: $elapsed seconds\n"; } - my ( $lock_dev, $lock_ino, $lock_mode, $lock_nlink, $lock_uid, $lock_gid, $lock_rdev, $lock_size, $lock_atime, $lock_mtime, $lock_ctime, $lock_blksize, $lock_blocks ) = stat($lock_file); - $repo_info->{locked_for} = time - $lock_ctime; - if ($verbose) { - print STDERR " Repo is locked for " . $repo_info->{locked_for} . " seconds\n"; - } - } else { - $repo_info->{error} = $error; - if ($verbose) { - print STDERR " ERROR: " . $error . "\n"; + eval { $info = decode_json($output_raw); }; + $tried_without_pass = 1; + } + if ($@) { + $error = $@; + if ( $output_raw =~ /lock.*lock\.exclusive/ ) { + $repo_info->{locked} = 1; + + my $lock_file = $config->{$repo}{repo} . '/lock.exclusive'; + if ($verbose) { + print STDERR " Repo is locked, checking: $lock_file\n"; + } + my ( $lock_dev, $lock_ino, $lock_mode, $lock_nlink, $lock_uid, $lock_gid, $lock_rdev, $lock_size, $lock_atime, $lock_mtime, $lock_ctime, $lock_blksize, $lock_blocks ) = stat($lock_file); + $repo_info->{locked_for} = time - $lock_ctime; + if ($verbose) { + print STDERR " Repo is locked for " . $repo_info->{locked_for} . " seconds\n"; + } + } else { + $repo_info->{error} = $error; + if ($verbose) { + print STDERR " ERROR: " . $error . "\n"; + } } } } else { From 88f48c6a135d7a750cc58efc3ee6f4c5751e5f24 Mon Sep 17 00:00:00 2001 From: Torstein EIde <1884894+Torstein-Eide@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:17:53 +0100 Subject: [PATCH 05/10] borgbackup: use borg list --json --last 1 for time_since_last_modified - Use borg list --json --last 1 to get the timestamp of the last archive - Only calculate time_since_last_modified if borg info succeeds (no error, not locked), skipping the value entirely if the repo can't be accessed - Fix missing my declarations for command variables --- snmp/borgbackup | 81 ++++++++++++++++++++++++++++++------------------- 1 file changed, 50 insertions(+), 31 deletions(-) diff --git a/snmp/borgbackup b/snmp/borgbackup index fe18d61c2..fe366e0a0 100755 --- a/snmp/borgbackup +++ b/snmp/borgbackup @@ -174,7 +174,7 @@ Totaled info is in the hash .totals. - .totals.locked_for :: Longest time any repo has been locked. - Type :: seconds - - .totals.time_since_last_modified :: Largest time - mtime for the repo directory + - .totals.time_since_last_modified :: Largest time since last archive was created - Type :: seconds - .total.total_chunks :: Total number of checks between all repos. @@ -205,7 +205,7 @@ Each repo then has it's own hash under .repo . locked. If it is not locked this is undef. - Type :: seconds - - .repo.$repo.time_since_last_modified :: time - mtime for the repo directory + - .repo.$repo.time_since_last_modified :: time since last archive was created - Type :: seconds - .repo.$repo.total_chunks :: Total number of checks for the repo. @@ -239,6 +239,7 @@ use MIME::Base64; use IO::Compress::Gzip qw(gzip $GzipError); use String::ShellQuote; use Pod::Usage; +use Time::Local; our $output_dir = '/var/cache/borgbackup_extend'; my $config_file = '/usr/local/etc/borgbackup_extend.ini'; @@ -519,38 +520,12 @@ foreach my $repo (@repos) { } } - if ($process) { + if ($process) { if ($verbose) { print STDERR " Repo path: " . $config->{$repo}{repo} . "\n"; } - my $nonce_file = $config->{$repo}{repo} . '/nonce'; - my $mtime; - if ( -e $nonce_file ) { - if ($verbose) { - print STDERR " Checking nonce file mtime: $nonce_file\n"; - } - ( undef, undef, undef, undef, undef, undef, undef, undef, undef, $mtime, undef, undef, undef ) = stat($nonce_file); - } else { - if ($verbose) { - print STDERR " No nonce file found, using repo directory mtime: " . $config->{$repo}{repo} . "\n"; - } - ( undef, undef, undef, undef, undef, undef, undef, undef, undef, $mtime, undef, undef, undef ) = stat( $config->{$repo}{repo} ); - } - - my $time_diff = time - $mtime; - $repo_info->{time_since_last_modified} = $time_diff; - if ($verbose) { - print STDERR " Time since last modified: $time_diff seconds\n"; - } - - # 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; - } + my $timeout = $config->{$repo}{timeout} || $default_timeout; if ( defined( $config->{$repo}{passcommand} ) ) { $ENV{BORG_PASSCOMMAND} = $config->{$repo}{passcommand}; @@ -575,7 +550,6 @@ foreach my $repo (@repos) { } } - my $timeout = $config->{$repo}{timeout} || $default_timeout; if ($verbose) { print STDERR " Timeout: " . ($timeout > 0 ? $timeout . " seconds" : "disabled") . "\n"; } @@ -642,6 +616,51 @@ foreach my $repo (@repos) { } } + if ( !defined( $repo_info->{error} ) && !$repo_info->{locked} ) { + my $list_cmd = 'borg list ' . shell_quote( $config->{$repo}{repo} ) . ' --json --last 1 2>&1'; + if ($timeout > 0) { + $list_cmd = 'timeout ' . $timeout . ' ' . $list_cmd; + } + if ($verbose) { + print STDERR " List Command: $list_cmd\n"; + } + my $list_start = time; + my $list_output = `$list_cmd`; + my $list_elapsed = time - $list_start; + if ($verbose) { + print STDERR " List Runtime: $list_elapsed seconds\n"; + } + + my $list_info; + eval { $list_info = decode_json($list_output); }; + if (!$@ && defined($list_info->{archives}) && scalar(@{$list_info->{archives}}) > 0) { + my $archive = $list_info->{archives}[0]; + my $archive_time = $archive->{time}; + if ($archive_time =~ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/) { + my $archive_timestamp = timelocal($6, $5, $4, $3, $2-1, $1-1900); + my $time_diff = time - $archive_timestamp; + $repo_info->{time_since_last_modified} = $time_diff; + if ($verbose) { + print STDERR " Last archive: $archive_time\n"; + print STDERR " Time since last modified: $time_diff seconds\n"; + } + 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; + } + } + } else { + if ($verbose) { + print STDERR " Could not get archive list, skipping time_since_last_modified\n"; + } + } + } else { + if ($verbose) { + print STDERR " Repo has error or is locked, skipping time_since_last_modified\n"; + } + } + for my $total (@totals) { $to_return->{data}{totals}{$total} = $to_return->{data}{totals}{$total} + $repo_info->{$total}; } From 712eba7ab45ce62aa7769e7a8e7c70447221679d Mon Sep 17 00:00:00 2001 From: Torstein EIde <1884894+Torstein-Eide@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:45:37 +0100 Subject: [PATCH 06/10] borgbackup: force numeric output for JSON values --- snmp/borgbackup | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/snmp/borgbackup b/snmp/borgbackup index fe366e0a0..f5ea6842a 100755 --- a/snmp/borgbackup +++ b/snmp/borgbackup @@ -317,6 +317,7 @@ sub finish { } my $j = JSON->new; + $j->allow_nonref(1); if ($pretty) { $j->pretty(1); $j->canonical(1); @@ -608,7 +609,7 @@ foreach my $repo (@repos) { } else { if ( defined( $info->{cache} ) && defined( $info->{cache}{stats} ) ) { for my $stat (@stats) { - $repo_info->{$stat} = $info->{cache}{stats}{$stat}; + $repo_info->{$stat} = $info->{cache}{stats}{$stat} + 0; } if ($verbose) { print STDERR " Stats: total_size=" . $repo_info->{total_size} . ", total_csize=" . $repo_info->{total_csize} . "\n"; From 01eb48384b8a8193b6dd63d787521a9175181376 Mon Sep 17 00:00:00 2001 From: Torstein EIde <1884894+Torstein-Eide@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:57:57 +0100 Subject: [PATCH 07/10] borgbackup: require -u for -p, exit with error if -p without -u --- snmp/borgbackup | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/snmp/borgbackup b/snmp/borgbackup index f5ea6842a..2774fc36e 100755 --- a/snmp/borgbackup +++ b/snmp/borgbackup @@ -379,13 +379,12 @@ my $to_return = { errorString => '', }; -if ( !$update && !$pretty ) { - my $cache_extra = ''; - if ($compress) { - $cache_extra = '.snmp'; +if ( !$update ) { + if ( $pretty ) { + die "Cannot pretty print without -u (update mode)\n"; } - if ( -f $output_dir . '/extend_return' . $cache_extra ) { - my $old = read_file( $output_dir . '/extend_return' . $cache_extra ); + if ( -f $output_dir . '/extend_return' ) { + my $old = read_file( $output_dir . '/extend_return' ); print $old; exit 0; } else { From a7d3d04d5f375f60c19722b66215063a47ae6356 Mon Sep 17 00:00:00 2001 From: Torstein EIde <1884894+Torstein-Eide@users.noreply.github.com> Date: Thu, 19 Mar 2026 07:25:45 +0100 Subject: [PATCH 08/10] borgbackup: add long argument names and --manual flag using pod2usage --- snmp/borgbackup | 78 ++++++++++++++++++++----------------------------- 1 file changed, 31 insertions(+), 47 deletions(-) diff --git a/snmp/borgbackup b/snmp/borgbackup index 2774fc36e..6aaa9fad0 100755 --- a/snmp/borgbackup +++ b/snmp/borgbackup @@ -32,7 +32,7 @@ borgbackup - LibreNMS JSON SNMP extend for gathering backups for borg =head1 SYNOPSIS -borgbackup [-c ] [-o ] [-u] [-p] [-Z] [-v] [-V] [-h] +borgbackup [-c|--config ] [-o|--output ] [-u|--update] [-p|--pretty] [-Z|--compress] [-v|--verbose] [-V|--version] [-h|--help] [-m|--manual] =head1 DESCRIPTION @@ -68,44 +68,48 @@ Use -u to update the cache, or -u -v for verbose updates. =head1 FLAGS -=head2 -c +=head2 -c|--config The config file to use for the extend. Default :: /usr/local/etc/borgbackup_extend.ini -=head2 -o +=head2 -o|--output The output directory write the pretty JSON file to and the file to use for the SNMP extend. Default :: /var/cache/borgbackup_extend -=head2 -u +=head2 -u|--update Update mode. When called from snmpd, it reads from the cache file instead of running borg commands directly. -=head2 -p +=head2 -p|--pretty Pretty print the JSON output. -=head2 -Z +=head2 -Z|--compress GZip+Base64 compress the results. -=head2 -v +=head2 -v|--verbose Verbose output. Print status info to STDERR. -=head2 -V +=head2 -V|--version Print version and exit. -=head2 -h +=head2 -h|--help Print help info. +=head2 -m|--manual + +Print full manual page using L. + =head1 CONFIG The config file is a ini file and handled by L. @@ -257,48 +261,28 @@ sub HELP_MESSAGE { print "borgbackup SNMP extend 0.1.0\n\n"; print "Usage: borgbackup [options]\n\n"; print "Options:\n"; - print " -c Config file (default: $config_file)\n"; - print " -o Output directory (default: $output_dir)\n"; - print " -u Update mode - regenerate cache from borg repos\n"; - print " -p Pretty print JSON output\n"; - print " -Z GZip+Base64 compress the cache file\n"; - print " -v Verbose output to STDERR\n"; - print " -V Print version and exit\n"; - print " -h Print this help message\n"; - print "\n"; - print "When called without -u, reads from cache file for SNMP extend.\n"; - print "\n"; - print "Typical usage:\n"; - print " SNMPD: extend borgbackup /bin/cat /var/cache/borgbackup_extend/extend_return\n"; - print " Cron: */5 * * * * /usr/local/lib/snmpd/borgbackup -u -Z\n"; - print "\n"; - print "Config file (INI format):\n"; - print " mode=multi # single or multi\n"; - print " repo=/path/to/repo # for single mode\n"; - print " passphrase=secret # or use passcommand\n"; - print " timeout=300 # seconds (0=disable)\n"; - print " cache_dir=/path/to/cache # BORG_CACHE_DIR\n"; - print "\n"; - print " [repo1] # for multi mode\n"; - print " repo=/path/to/repo1\n"; - print " passphrase=secret1\n"; - print " timeout=600 # per-repo override\n"; + print " -c, --config Config file (default: $config_file)\n"; + print " -o, --output Output directory (default: $output_dir)\n"; + print " -u, --update Update mode - regenerate cache from borg repos\n"; + print " -p, --pretty Pretty print JSON output\n"; + print " -Z, --compress GZip+Base64 compress the cache file\n"; + print " -v, --verbose Verbose output to STDERR\n"; + print " -V, --version Print version and exit\n"; + print " -h, --help Print this help message\n"; + print " -m, --manual Print full manual page\n"; print "\n"; - print " [repo2]\n"; - print " repo=/path/to/repo2\n"; - print " passcommand=pass show repo2\n"; - print " cache_dir=/tmp/borg-cache\n"; } GetOptions( - 'c=s' => \$config_file, - 'o=s' => \$output_dir, - 'v' => \$verbose, - 'p' => \$pretty, - 'Z' => \$compress, - 'u' => \$update, - 'V' => sub { VERSION_MESSAGE(); exit 0; }, - 'h' => sub { HELP_MESSAGE(); exit 0; }, + 'c|config=s' => \$config_file, + 'o|output=s' => \$output_dir, + 'v|verbose' => \$verbose, + 'p|pretty' => \$pretty, + 'Z|compress' => \$compress, + 'u|update' => \$update, + 'V|version' => sub { VERSION_MESSAGE(); exit 0; }, + 'h|help' => sub { HELP_MESSAGE(); exit 0; }, + 'm|manual' => sub { pod2usage(-verbose => 2) }, ) or die "Error in command line arguments\n"; if ($compress && !$update) { die "-Z requires -u (update mode) for compression\n"; From 562a6f65d9c1d56db6841afd5b3684bab07bc386 Mon Sep 17 00:00:00 2001 From: Torstein EIde <1884894+Torstein-Eide@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:26:17 +0100 Subject: [PATCH 09/10] borgbackup: refactor cache read logic to support -p without -u --- snmp/borgbackup | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/snmp/borgbackup b/snmp/borgbackup index 6aaa9fad0..d73f755fc 100755 --- a/snmp/borgbackup +++ b/snmp/borgbackup @@ -284,9 +284,21 @@ GetOptions( 'h|help' => sub { HELP_MESSAGE(); exit 0; }, 'm|manual' => sub { pod2usage(-verbose => 2) }, ) or die "Error in command line arguments\n"; -if ($compress && !$update) { - die "-Z requires -u (update mode) for compression\n"; + +if ( ( $compress || $pretty ) && !$update ) { + die "Cannot use -Z|--compress or -p|--pretty without -u (update mode)\n"; +} + +if ( !$update ) { + if ( -f $output_dir . '/extend_return' ) { + my $old = read_file( $output_dir . '/extend_return' ); + print $old; + exit 0; + } else { + die "Cache file not found. Run with -u to update cache.\n"; + } } + if ($verbose) { warn "DEBUG: verbose=$verbose, update=$update, pretty=$pretty, compress=$compress\n"; } @@ -363,19 +375,6 @@ my $to_return = { errorString => '', }; -if ( !$update ) { - if ( $pretty ) { - die "Cannot pretty print without -u (update mode)\n"; - } - if ( -f $output_dir . '/extend_return' ) { - my $old = read_file( $output_dir . '/extend_return' ); - print $old; - exit 0; - } else { - die "Cache file not found. Run with -u to update cache.\n"; - } -} - if ($verbose) { print STDERR "Processing repos in mode: update\n"; } From f84b4e1b13389c3f72188c763a1efb6429a7f45c Mon Sep 17 00:00:00 2001 From: Torstein EIde <1884894+Torstein-Eide@users.noreply.github.com> Date: Mon, 23 Mar 2026 07:55:11 +0100 Subject: [PATCH 10/10] WIP: working on .env support --- snmp/borgbackup | 578 +++++++++++++++++++++++++++++------------------- 1 file changed, 350 insertions(+), 228 deletions(-) diff --git a/snmp/borgbackup b/snmp/borgbackup index d73f755fc..c7d20a5e3 100755 --- a/snmp/borgbackup +++ b/snmp/borgbackup @@ -136,6 +136,19 @@ The config file is a ini file and handled by L. - cache_dir :: BORG_CACHE_DIR environment variable for borg cache location. - Default :: undef + - env_file :: Path to an environment file to load (shell format: KEY=value). + - Default :: undef + + - repo :: Can be a local path or remote SSH URL. + + Local path: /path/to/repo + + Restic-style SSH: user@host:/path/to/repo + + Standard SSH URL: ssh://user@host/path/to/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'. @@ -158,6 +171,29 @@ of 'pass show backup' it would be like below. If 'passphrase' and 'passcommand' are both specified, then passcommand is used. +For remote repos, set env_file in the config to load BORG_PASSPHRASE or +BORG_PASSCOMMAND, or set other environment variables like SSH_AUTH_SOCK. + +Example with env file: + + mode=multi + env_file=/etc/borgbackup.env + + [server2] + repo=eideen_borg:/volume2/borg-backup/server2/ + + [server3] + repo=ssh://backup@server3/volume1/borg + +And the env file (/etc/borgbackup.env): + + # Borg authentication + BORG_PASSCOMMAND=pass show borg/server2 + + # SSH setup + SSH_AUTH_SOCK=/run/user/1000/gnupg/S.gpg-agent.ssh + BORG_RSH=ssh -i /root/.ssh/borg_key + =head1 JSON RETURN The return is a LibreNMS JSON style SNMP extend as defined at @@ -244,13 +280,14 @@ use IO::Compress::Gzip qw(gzip $GzipError); use String::ShellQuote; use Pod::Usage; use Time::Local; +use POSIX qw(strftime); our $output_dir = '/var/cache/borgbackup_extend'; my $config_file = '/usr/local/etc/borgbackup_extend.ini'; -my $verbose = 0; -my $pretty = 0; -my $compress = 0; -my $update = 0; +my $verbose = 0; +my $pretty = 0; +my $compress = 0; +my $update = 0; my $default_timeout = 300; sub VERSION_MESSAGE { @@ -273,20 +310,125 @@ sub HELP_MESSAGE { print "\n"; } +# ANSI farger +my %COLOR = ( + reset => "\e[0m", + blue => "\e[94m", + green => "\e[92m", + yellow => "\e[93m", + red => "\e[91m", +); + +## logging og utils +##################################### +# Default nivÄ (5 = info) +my $VERBOSE = 0; +my $QUIET = 0; +my $DEBUG = 0; +my $LOG_LEVEL; + +sub _date { + return scalar localtime(); +} + +sub _log { + my ($level, $color, @msg) = @_; + + # Quiet = kun warn(4) og opp (lavere tall = mer alvorlig) + if ($QUIET) { + return if $level > 4; + } else { + # Normal filtrering + return if $level > $LOG_LEVEL; + } + + my $prefix = _date() . ": "; + my $text = join(" ", @msg); + my $output = $prefix . $text . "\n"; + + if ($color) { + $output = $COLOR{$color} . $output . $COLOR{reset}; + } + + # STDERR for warn og mer alvorlig + if ($level <= 4) { + print STDERR $output; + } else { + print STDOUT $output; + } +} + +sub log_debug { _log(8, 'blue', "DEBUG:", @_); } +sub log_verbose { _log(7, 'blue', "VERBOSE:", @_); } +sub log_notice { _log(6, 'green', @_); } +sub log_info { _log(5, undef, @_); } +sub log_warn { _log(4, 'yellow', @_); } +sub log_error { _log(3, 'red', @_); } +sub log_alert { _log(2, 'red', "ALERT:", @_); } +sub log_critical { _log(1, 'red', "CRITICAL:", @_); } + + +##################################### +sub read_env_file { + my ($file) = @_; + + if ( !-f $file ) { + die "Environment file not found: $file\n"; + } + + open my $fh, '<', $file or die "Cannot open env file $file: $!\n"; + while ( my $line = <$fh> ) { + chomp $line; + next if $line =~ /^\s*$/; + next if $line =~ /^\s*#/; + $line =~ s/^export\s+//; + if ( $line =~ /^([^=]+)=(.*)$/ ) { + my $key = $1; + my $value = $2; + $key =~ s/^\s+|\s+$//g; + $value =~ s/^\s+|\s+$//g; + $ENV{$key} = $value; + log_verbose("ENV: Loaded env: $key"); + } + } + close $fh; +} + +sub is_remote_repo { + my ($repo) = @_; + return 0 if !defined($repo); + return 1 if $repo =~ m{^ssh://}i; + my $has_colon = index($repo, ':'); + return 1 if $has_colon > 0 && substr($repo, 0, 1) ne '/'; + return 0; +} + +sub get_repo_lock_file { + my ($repo) = @_; + return undef if is_remote_repo($repo); + return $repo . '/lock.exclusive'; +} + GetOptions( 'c|config=s' => \$config_file, 'o|output=s' => \$output_dir, - 'v|verbose' => \$verbose, 'p|pretty' => \$pretty, 'Z|compress' => \$compress, 'u|update' => \$update, - 'V|version' => sub { VERSION_MESSAGE(); exit 0; }, + 'V|version' => sub { VERSION_MESSAGE(); exit 0; }, 'h|help' => sub { HELP_MESSAGE(); exit 0; }, 'm|manual' => sub { pod2usage(-verbose => 2) }, + 'v|verbose' => \$VERBOSE, + 'q|quiet' => \$QUIET, + 'd|debug' => \$DEBUG, ) or die "Error in command line arguments\n"; -if ( ( $compress || $pretty ) && !$update ) { - die "Cannot use -Z|--compress or -p|--pretty without -u (update mode)\n"; +if ( $compress && !$update ) { + die "Cannot use -Z|--compress without -u (update mode)\n"; +} + +if ( $pretty && !$update ) { + die "Cannot use -p|--pretty without -u (update mode)\n"; } if ( !$update ) { @@ -299,10 +441,28 @@ if ( !$update ) { } } -if ($verbose) { - warn "DEBUG: verbose=$verbose, update=$update, pretty=$pretty, compress=$compress\n"; +# Ensure only one (or none) is set +if ( ($VERBOSE ? 1 : 0) + ($QUIET ? 1 : 0) > 1 ) { + die "You cannot use both --verbose and --quiet at the same time\n"; +} + +# Set LOG_LEVEL +if ($VERBOSE) { + $LOG_LEVEL = 7; # everything +} +elsif ($DEBUG) { + $LOG_LEVEL = 8; # debug and above +} +elsif ($QUIET) { + $LOG_LEVEL = 4; # warn and above +} +else { + $LOG_LEVEL = 5; # default (info) } +log_info("MAIN: Starting borgbackup in update mode"); + +####################################### sub finish { my (%opts) = @_; @@ -314,7 +474,7 @@ sub finish { my $j = JSON->new; $j->allow_nonref(1); - if ($pretty) { + if ( $opts{pretty} ) { $j->pretty(1); $j->canonical(1); } @@ -328,30 +488,28 @@ sub finish { $compressed =~ s/\n//g; $compressed = $compressed . "\n"; if ( length($compressed) > length($return_string) ) { - if ($verbose) { - print STDERR "Writing uncompressed extend_return to: $output_dir/extend_return\n"; - } + log_verbose("OUTPUT: Writing uncompressed extend_return to: $output_dir/extend_return"); write_file( $output_dir . '/extend_return', $return_string ); } else { - if ($verbose) { - print STDERR "Writing compressed extend_return to: $output_dir/extend_return\n"; - } + log_verbose("OUTPUT: Writing compressed extend_return to: $output_dir/extend_return"); write_file( $output_dir . '/extend_return', $compressed ); } } else { - if ($verbose) { - print STDERR "Writing extend_return to: $output_dir/extend_return\n"; - } + log_verbose("OUTPUT: Writing extend_return to: $output_dir/extend_return"); write_file( $output_dir . '/extend_return', $return_string ); } - if ($verbose) { - print STDERR "Writing pretty JSON to: $output_dir/pretty\n"; - } - write_file( $output_dir . '/pretty', $return_string ); + log_verbose("OUTPUT: Writing pretty JSON to: $output_dir/pretty"); + + my $j_pretty = JSON->new; + $j_pretty->allow_nonref(1); + $j_pretty->pretty(1); + $j_pretty->canonical(1); + my $pretty_string = $j_pretty->encode( $opts{to_return} ); + write_file( $output_dir . '/pretty', $pretty_string ); exit $opts{to_return}->{error}; -} ## end sub finish +} my $to_return = { data => { @@ -375,11 +533,8 @@ my $to_return = { errorString => '', }; -if ($verbose) { - print STDERR "Processing repos in mode: update\n"; -} +log_verbose("CONFIG: Config file: $config_file"); -# attempt to read in the config my $config; eval { my $raw_config = read_file($config_file); @@ -388,98 +543,95 @@ eval { if ($@) { $to_return->{error} = 1; $to_return->{errorString} = 'Failed reading config file "' . $config_file . '"... ' . $@; - finish( to_return => $to_return ); + finish( to_return => $to_return, pretty => $pretty ); } -if ($verbose) { - print STDERR "Config file: $config_file\n"; +if ( defined( $config->{_}{env_file} ) && $config->{_}{env_file} ne '' ) { + my $config_env_file = $config->{_}{env_file}; + log_verbose("CONFIG: Loading global env file: $config_env_file"); + read_env_file($config_env_file); } if ( !defined( $config->{_}{mode} ) ) { $config->{_}{mode} = 'single'; - if ($verbose) { - print STDERR "Config: mode not set, defaulting to 'single'\n"; - } + log_verbose("CONFIG: Mode not set, defaulting to '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 ); + finish( to_return => $to_return, pretty => $pretty ); } $to_return->{data}{mode} = $config->{_}{mode}; -if ($verbose) { - print STDERR "Config: mode set to '" . $config->{_}{mode} . "'\n"; -} +log_verbose("CONFIG: Mode set to '" . $config->{_}{mode} . "'"); -# get a list of repos to use my @repos; + +# For single mode, we just use the root section of the config and add a fake repo called 'single' to make the rest of the code easier. For multi, we look for all sections that aren't '_' or 'env_file' and treat those as repos. +############################################################################### if ( $config->{_}{mode} eq 'single' ) { - # if single, just create a single repo push( @repos, 'single' ); $config->{single} = {}; - if ($verbose) { - print STDERR "Config: single mode, using root section config\n"; - } + log_verbose("CONFIG: Single mode, using root section config"); - # store passphrase/passcommand if provided if ( defined( $config->{_}{passphrase} ) ) { $config->{single}{passphrase} = $config->{_}{passphrase}; - if ($verbose) { - print STDERR "Config: using passphrase from root section\n"; - } + log_verbose("CONFIG: Using passphrase from root section"); } elsif ( defined( $config->{_}{passcommand} ) ) { $config->{single}{passcommand} = $config->{_}{passcommand}; - if ($verbose) { - print STDERR "Config: using passcommand from root section\n"; - } + log_verbose("CONFIG: Using passcommand from root section"); } else { - if ($verbose) { - print STDERR "Config: no passphrase/passcommand, will try without\n"; - } + log_verbose("CONFIG: No passphrase/passcommand, will try without"); } - # store cache_dir if provided if ( defined( $config->{_}{cache_dir} ) ) { $config->{single}{cache_dir} = $config->{_}{cache_dir}; - if ($verbose) { - print STDERR "Config: using cache_dir from root section\n"; - } + log_verbose("CONFIG: Using cache_dir from root section"); } - # 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 ); + finish( to_return => $to_return, pretty => $pretty ); } $config->{single}{repo} = $config->{_}{repo}; - if ($verbose) { - print STDERR "Config: repo path: " . $config->{_}{repo} . "\n"; - } + log_verbose("CONFIG: Repo path: " . $config->{_}{repo}); +#### Multi mode, we look for all sections that aren't '_' or 'env_file' and treat those as repos. } else { - # we don't want _ as that is the root of the ini file - @repos = grep( !/^\_$/, keys( %{$config} ) ); - if ($verbose) { - print STDERR "Config: multi mode, found " . scalar(@repos) . " repos: " . join(', ', @repos) . "\n"; - } + @repos = grep( !/^\_$/ && !/^env_file$/, keys( %{$config} ) ); + log_verbose("CONFIG: Multi mode, found " . scalar(@repos) . " repos: " . join(', ', @repos)); } -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' ); +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' ); -print "Processing " . scalar(@repos) . " repos...\n"; +log_verbose("MAIN: Processing " . scalar(@repos) . " repos..."); foreach my $repo (@repos) { - print "Processing repo $repo\n"; - - my $process = 1; + log_verbose($repo, "Starting processing"); - # unset borg env bits delete( $ENV{BORG_PASSPHRASE} ); delete( $ENV{BORG_PASSCOMMAND} ); - delete( $ENV{BORG_CACHE_DIR} ); + delete( $ENV{BORG_REPO} ); + + if ( defined( $config->{$repo}{env_file} ) && $config->{$repo}{env_file} ne '' ) { + my $repo_env_file = $config->{$repo}{env_file}; + log_verbose($repo, "Loading env file: $repo_env_file"); + read_env_file($repo_env_file); + } + + my $repo_path = $config->{$repo}{repo}; + if ( !defined($repo_path) || $repo_path eq '' ) { + $repo_path = $ENV{BORG_REPO}; + if (defined($repo_path) && $repo_path ne '') { + log_verbose($repo, "Using BORG_REPO from env: $repo_path"); + } + } + + if ( !defined($repo_path) || $repo_path eq '' ) { + log_verbose($repo, "Skipping - no repo defined"); + next; + } my $repo_info = { total_chunks => 0, @@ -494,180 +646,150 @@ foreach my $repo (@repos) { locked_for => undef, }; - 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 ($verbose) { - print STDERR " ERROR: No repo path defined for $repo\n"; - } - } + log_verbose($repo, "Repo path: " . $repo_path); - if ($process) { - if ($verbose) { - print STDERR " Repo path: " . $config->{$repo}{repo} . "\n"; - } + log_verbose($repo, "BORG_REPO env: " . (defined($ENV{BORG_REPO}) ? $ENV{BORG_REPO} : 'undef')); + log_verbose($repo, "BORG_PASSPHRASE env: " . (defined($ENV{BORG_PASSPHRASE}) ? $ENV{BORG_PASSPHRASE} : 'undef')); + log_verbose($repo, "BORG_CACHE_DIR env: " . (defined($ENV{BORG_CACHE_DIR}) ? $ENV{BORG_CACHE_DIR} : 'undef')); - my $timeout = $config->{$repo}{timeout} || $default_timeout; + my $timeout = $config->{$repo}{timeout} || $default_timeout; - if ( defined( $config->{$repo}{passcommand} ) ) { - $ENV{BORG_PASSCOMMAND} = $config->{$repo}{passcommand}; - if ($verbose) { - print STDERR " Auth: using passcommand\n"; - } - } elsif ( defined( $config->{$repo}{passphrase} ) ) { - $ENV{BORG_PASSPHRASE} = $config->{$repo}{passphrase}; - if ($verbose) { - print STDERR " Auth: using passphrase\n"; - } - } else { - if ($verbose) { - print STDERR " Auth: none configured, trying without\n"; - } - } + if ( defined( $config->{$repo}{passcommand} ) ) { + $ENV{BORG_PASSCOMMAND} = $config->{$repo}{passcommand}; + log_verbose($repo, "Auth: using passcommand"); + } elsif ( defined( $config->{$repo}{passphrase} ) ) { + $ENV{BORG_PASSPHRASE} = $config->{$repo}{passphrase}; + log_verbose($repo, "Auth: using passphrase"); + } elsif ( defined( $ENV{BORG_PASSCOMMAND} ) && $ENV{BORG_PASSCOMMAND} ne '' ) { + log_verbose($repo, "Auth: using BORG_PASSCOMMAND from env"); + } elsif ( defined( $ENV{BORG_PASSPHRASE} ) && $ENV{BORG_PASSPHRASE} ne '' ) { + log_verbose($repo, "Auth: using BORG_PASSPHRASE from env"); + } else { + log_verbose($repo, "Auth: none configured, trying without"); + } - if ( defined( $config->{$repo}{cache_dir} ) ) { - $ENV{BORG_CACHE_DIR} = $config->{$repo}{cache_dir}; - if ($verbose) { - print STDERR " Cache dir: $ENV{BORG_CACHE_DIR}\n"; - } - } + if ( defined( $config->{$repo}{cache_dir} ) ) { + $ENV{BORG_CACHE_DIR} = $config->{$repo}{cache_dir}; + log_verbose($repo, "Cache dir: $ENV{BORG_CACHE_DIR}"); + } elsif ( defined( $ENV{BORG_CACHE_DIR} ) && $ENV{BORG_CACHE_DIR} ne '' ) { + log_verbose($repo, "Cache dir: using BORG_CACHE_DIR from env"); + } - if ($verbose) { - print STDERR " Timeout: " . ($timeout > 0 ? $timeout . " seconds" : "disabled") . "\n"; - } - my $command = 'borg info ' . shell_quote( $config->{$repo}{repo} ) . ' --json 2>&1'; - if ($timeout > 0) { - $command = 'timeout ' . $timeout . ' ' . $command; - } - if ($verbose) { - print STDERR " Command: $command\n"; - } - my $start_time = time; - my $output_raw = `$command`; - my $elapsed = time - $start_time; - if ($verbose) { - print STDERR " Runtime: $elapsed seconds\n"; + log_verbose($repo, "Timeout: " . ($timeout > 0 ? $timeout . " seconds" : "disabled")); + my $command = 'borg info ' . shell_quote($repo_path) . ' --json 2>&1'; + if ($timeout > 0) { + $command = 'timeout ' . $timeout . ' ' . $command; + } + log_verbose($repo, "Command: $command"); + log_debug($repo, "Environment for command: " . join(', ', map { "$_=" . (defined($ENV{$_}) ? $ENV{$_} : 'undef') } sort keys %ENV)); + my $start_time = time; + my $output_raw = `$command`; + my $elapsed = time - $start_time; + log_verbose($repo, "Runtime: $elapsed seconds"); + log_debug($repo, "borg output: $output_raw"); + + my $info; + eval { $info = decode_json($output_raw); }; + if ($@) { + my $error = $@; + if ( !defined( $config->{$repo}{passcommand} ) && !defined( $config->{$repo}{passphrase} ) ) { + delete $ENV{BORG_PASSPHRASE}; + delete $ENV{BORG_PASSCOMMAND}; + $output_raw = `$command`; + $elapsed = time - $start_time; + log_verbose($repo, "No passphrase configured, retrying without..."); + log_verbose($repo, "Runtime: $elapsed seconds"); + eval { $info = decode_json($output_raw); }; } - - my $info; - eval { $info = decode_json($output_raw); }; if ($@) { - my $error = $@; - my $tried_without_pass = 0; - if ( !defined( $config->{$repo}{passcommand} ) && !defined( $config->{$repo}{passphrase} ) ) { - delete $ENV{BORG_PASSPHRASE}; - delete $ENV{BORG_PASSCOMMAND}; - $output_raw = `$command`; - $elapsed = time - $start_time; - if ($verbose) { - print STDERR " No passphrase configured, retrying without...\n"; - print STDERR " Runtime: $elapsed seconds\n"; - } - eval { $info = decode_json($output_raw); }; - $tried_without_pass = 1; - } - if ($@) { - $error = $@; - if ( $output_raw =~ /lock.*lock\.exclusive/ ) { - $repo_info->{locked} = 1; - - my $lock_file = $config->{$repo}{repo} . '/lock.exclusive'; - if ($verbose) { - print STDERR " Repo is locked, checking: $lock_file\n"; - } + $error = $@; + if ( $output_raw =~ /lock.*lock\.exclusive/ ) { + $repo_info->{locked} = 1; + my $lock_file = get_repo_lock_file($repo_path); + if ( defined($lock_file) && -e $lock_file ) { + log_verbose($repo, "Repo is locked, checking: $lock_file"); my ( $lock_dev, $lock_ino, $lock_mode, $lock_nlink, $lock_uid, $lock_gid, $lock_rdev, $lock_size, $lock_atime, $lock_mtime, $lock_ctime, $lock_blksize, $lock_blocks ) = stat($lock_file); $repo_info->{locked_for} = time - $lock_ctime; - if ($verbose) { - print STDERR " Repo is locked for " . $repo_info->{locked_for} . " seconds\n"; - } + log_verbose($repo, "Repo is locked for " . $repo_info->{locked_for} . " seconds"); + } elsif ( is_remote_repo($repo_path) ) { + log_verbose($repo, "Repo is locked (remote repo, cannot determine lock time)"); + $repo_info->{locked_for} = undef; } else { - $repo_info->{error} = $error; - if ($verbose) { - print STDERR " ERROR: " . $error . "\n"; - } - } - } - } else { - if ( defined( $info->{cache} ) && defined( $info->{cache}{stats} ) ) { - for my $stat (@stats) { - $repo_info->{$stat} = $info->{cache}{stats}{$stat} + 0; - } - if ($verbose) { - print STDERR " Stats: total_size=" . $repo_info->{total_size} . ", total_csize=" . $repo_info->{total_csize} . "\n"; + log_verbose($repo, "Repo is locked, lock file not found locally"); + $repo_info->{locked_for} = undef; } + } else { + $repo_info->{error} = $error; + log_error($repo, "ERROR: $error"); } } - - if ( !defined( $repo_info->{error} ) && !$repo_info->{locked} ) { - my $list_cmd = 'borg list ' . shell_quote( $config->{$repo}{repo} ) . ' --json --last 1 2>&1'; - if ($timeout > 0) { - $list_cmd = 'timeout ' . $timeout . ' ' . $list_cmd; - } - if ($verbose) { - print STDERR " List Command: $list_cmd\n"; - } - my $list_start = time; - my $list_output = `$list_cmd`; - my $list_elapsed = time - $list_start; - if ($verbose) { - print STDERR " List Runtime: $list_elapsed seconds\n"; + } else { + if ( defined( $info->{cache} ) && defined( $info->{cache}{stats} ) ) { + for my $stat (@stats) { + $repo_info->{$stat} = $info->{cache}{stats}{$stat} + 0; } + log_verbose($repo, "Stats: total_size=" . $repo_info->{total_size} . ", total_csize=" . $repo_info->{total_csize}); + } + } - my $list_info; - eval { $list_info = decode_json($list_output); }; - if (!$@ && defined($list_info->{archives}) && scalar(@{$list_info->{archives}}) > 0) { - my $archive = $list_info->{archives}[0]; - my $archive_time = $archive->{time}; - if ($archive_time =~ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/) { - my $archive_timestamp = timelocal($6, $5, $4, $3, $2-1, $1-1900); - my $time_diff = time - $archive_timestamp; - $repo_info->{time_since_last_modified} = $time_diff; - if ($verbose) { - print STDERR " Last archive: $archive_time\n"; - print STDERR " Time since last modified: $time_diff seconds\n"; - } - 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; - } - } - } else { - if ($verbose) { - print STDERR " Could not get archive list, skipping time_since_last_modified\n"; + if ( !defined( $repo_info->{error} ) && !$repo_info->{locked} ) { + my $list_cmd = 'borg list ' . shell_quote($repo_path) . ' --json --last 1 2>&1'; + if ($timeout > 0) { + $list_cmd = 'timeout ' . $timeout . ' ' . $list_cmd; + } + # Execute the borg list command and measure execution time + my $list_start = time; + + my $list_output = `$list_cmd`; + my $list_elapsed = time - $list_start; + log_verbose($repo, "List Runtime: $list_elapsed seconds"); + + my $list_info; + eval { $list_info = decode_json($list_output); }; + if (!$@ && defined($list_info->{archives}) && scalar(@{$list_info->{archives}}) > 0) { + my $archive = $list_info->{archives}[0]; + my $archive_time = $archive->{time}; + if ($archive_time =~ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/) { + my $archive_timestamp = timelocal($6, $5, $4, $3, $2-1, $1-1900); + my $time_diff = time - $archive_timestamp; + $repo_info->{time_since_last_modified} = $time_diff; + log_verbose($repo, "Last archive: $archive_time"); + log_verbose($repo, "Time since last modified: $time_diff seconds"); + 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; } } } else { - if ($verbose) { - print STDERR " Repo has error or is locked, skipping time_since_last_modified\n"; - } + log_warn($repo, "Could not get archive list, skipping time_since_last_modified"); } + } else { + log_error($repo, "Repo has error or is locked, skipping time_since_last_modified"); + } - for my $total (@totals) { - $to_return->{data}{totals}{$total} = $to_return->{data}{totals}{$total} + $repo_info->{$total}; - } + 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( $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) + if ( !defined( $to_return->{data}{totals}{locked_for} ) + || ( defined($repo_info->{locked_for}) && $to_return->{data}{totals}{locked_for} < $repo_info->{locked_for} ) ) + { + $to_return->{data}{totals}{locked_for} = $repo_info->{locked_for}; + } $to_return->{data}{repos}{$repo} = $repo_info; -} ## end foreach my $repo (@repos) - -if ($verbose) { - print STDERR "Totals:\n"; - print STDERR " errored: " . $to_return->{data}{totals}{errored} . "\n"; - print STDERR " locked: " . $to_return->{data}{totals}{locked} . "\n"; - print STDERR " total_size: " . $to_return->{data}{totals}{total_size} . "\n"; - print STDERR " total_csize: " . $to_return->{data}{totals}{total_csize} . "\n"; + log_info($repo, "Finished processing"); } -finish( to_return => $to_return ); +log_verbose("MAIN: Totals:"); +log_verbose("MAIN: errored: " . $to_return->{data}{totals}{errored}); +log_verbose("MAIN: locked: " . $to_return->{data}{totals}{locked}); +log_verbose("MAIN: total_size: " . $to_return->{data}{totals}{total_size}); +log_verbose("MAIN: total_csize: " . $to_return->{data}{totals}{total_csize}); + +finish( to_return => $to_return, pretty => $pretty );