diff options
-rwxr-xr-x | borg-restore.pl | 859 |
1 files changed, 0 insertions, 859 deletions
diff --git a/borg-restore.pl b/borg-restore.pl deleted file mode 100755 index 0c52c9b..0000000 --- a/borg-restore.pl +++ /dev/null @@ -1,859 +0,0 @@ -#!/usr/bin/perl -T -use warnings; -use strict; - -=head1 NAME - -borg-restore.pl - Restore paths from borg backups - -=head1 SYNOPSIS - -borg-restore.pl [options] <path> - - Options: - --help, -h short help message - --debug show debug messages - --update-cache, -u update cache files - --destination, -d <path> Restore backup to directory <path> - --time, -t <timespec> Automatically find newest backup that is at least - <time spec> old - - Time spec: - Select the newest backup that is at least <time spec> old. - Format: <number><unit> - Units: s (seconds), min (minutes), h (hours), d (days), m (months = 31 days), y (year) - -=head1 DESCRIPTION - -borg-restore.pl helps to restore files from borg backups. - -It takes one path, looks for its backups, shows a list of distinct versions and -allows to select one to be restored. Versions are based on the modification -time of the file. - -It is also possible to specify a time for automatic selection of the backup -that has to be restored. If a time is specified, the script will automatically -select the newest backup that is at least as old as the time value that is -passed and restore it without further user interaction. - -B<borg-restore.pl --update-cache> has to be executed regularly, ideally after -creating or removing backups. - -=cut - -=head1 OPTIONS - -=over 4 - -=item B<--help>, B<-h> - -Show help message. - -=item B<--debug> - -Enable debug messages. - -=item B<--update-cache>, B<-u> - -Update the lookup database. You should run this after creating or removing a backup. - -=item B<--destination=>I<path>, B<-d >I<path> - -Restore the backup to 'path' instead of its original location. The destination -either has to be a directory or missing in which case it will be created. The -backup will then be restored into the directory with its original file or -directory name. - -=item B<--time=>I<timespec>, B<-t >I<timespec> - -Automatically find the newest backup that is at least as old as I<timespec> -specifies. I<timespec> is a string of the form "<I<number>><I<unit>>" with I<unit> being one of the following: -s (seconds), min (minutes), h (hours), d (days), m (months = 31 days), y (year). Example: 5.5d - -=back - -=head1 CONFIGURATION - -borg-restore.pl searches for configuration files in the following locations in -order. The first file found will be used, any later ones are ignored. If no -files are found, defaults are used. - -=over - -=item * $XDG_CONFIG_HOME/borg-restore.cfg - -=item * /etc/borg-restore.cfg - -=back - -=head2 Configuration Options - -You can set the following options in the config file. - -Note that the configuration file is parsed as a perl script. Thus you can also -use any features available in perl itself. - -=over - -=item C<$borg_repo> - -This specifies the URL to the borg repo as used in other borg commands. If you -use the $BORG_REPO environment variable leave this empty. - -=item C<$cache_path_base> - -This defaults to "C<$XDG_CACHE_HOME>/borg-restore.pl". It contains the lookup database. - -=item C<@backup_prefixes> - -This is an array of prefixes that need to be added when looking up a file in the -backup archives. If you use filesystem snapshots and the snapshot for /home is -located at /mnt/snapshots/home, you have to add the following: - -# In the backup archives, /home has the path /mnt/snapshots/home -{regex => "^/home/", replacement => "mnt/snapshots/home/"}, - -The regex must always include the leading slash and it is suggested to include -a tailing slash as well to prevent clashes with directories that start with the -same string. The first regex that matches for a given file is used. This -setting only affects lookups, it does not affect the creation of the database -with --update-database. - -=back - -=head2 Example Configuration - - $borg_repo = "/path/to/repo"; - $cache_path_base = "/mnt/somewhere/borg-restore.pl-cache"; - @backup_prefixes = ( - {regex => "^/home/", replacement => "mnt/snapshots/home/"}, - # /boot is not snapshotted - {regex => "^/boot", replacement => ""}, - {regex => "^/", replacement => "mnt/snapshots/root/"}, - ); - -=head1 LICENSE - -Copyright (C) 2016-2017 Florian Pritz <bluewind@xinu.at> - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see <http://www.gnu.org/licenses/>. - -See gpl-3.0.txt for the full license text. - -=cut - -use v5.10; - -{ -package Settings; - our $borg_repo = ""; - our $cache_path_base = sprintf("%s/borg-restore.pl", $ENV{XDG_CACHE_HOME} // $ENV{HOME}."/.cache"); - our @backup_prefixes = ( - {regex => "^/", replacement => ""}, - ); - - my @configfiles = ( - sprintf("%s/borg-restore.cfg", $ENV{XDG_CONFIG_HOME} // $ENV{HOME}."/.config"), - "/etc/borg-restore.cfg", - ); - - for my $configfile (@configfiles) { - $configfile = Helper::untaint($configfile, qr/.*/); - if (-e $configfile) { - unless (my $return = do $configfile) { - die "couldn't parse $configfile: $@" if $@; - die "couldn't do $configfile: $!" unless defined $return; - die "couldn't run $configfile" unless $return; - } - } - } - $cache_path_base = Helper::untaint($cache_path_base, qr/.*/); -} - -package main; - -use autodie; -use Cwd qw(abs_path getcwd); -use Data::Dumper; -use DateTime; -use File::Basename; -use File::Path qw(mkpath); -use File::Slurp; -use File::Spec; -use File::Temp; -use Getopt::Long; -use IPC::Run qw(run start); -use List::Util qw(any all); -use Pod::Usage; -use Time::HiRes; - -my %opts; -my %db; - -sub debug { - say STDERR @_ if $opts{debug}; -} - -sub borg_list { - my @archives; - - run [qw(borg list)], '>', \my $output or die "borg list returned $?"; - - for (split/^/, $output) { - if (m/^([^\s]+)\s/) { - push @archives, $1; - } - } - - return \@archives; -} - -sub find_archives { - my $path = shift; - - my $db_path = get_cache_path('archives.db'); - - my $db = open_db($db_path); - - my %seen_modtime; - my @ret; - - debug("Building unique archive list"); - - my $archives = $db->get_archives_for_path($path); - - for my $archive (@$archives) { - my $modtime = $archive->{modification_time}; - - if (defined($modtime) && (!$seen_modtime{$modtime}++)) { - push @ret, $archive; - } - } - - if (!@ret) { - printf "\e[0;91mWarning:\e[0m Path '%s' not found in any archive.\n", $path; - } - - @ret = sort { $a->{modification_time} cmp $b->{modification_time} } @ret; - - return \@ret; -} - -sub user_select_archive { - my $archives = shift; - - my $selected_archive; - - my $counter = 0; - - if (!@$archives) { - return; - } - - for my $archive (@$archives) { - printf "\e[0;33m%3d: \e[1;33m%s\e[0m %s\n", $counter++, format_timestamp($archive->{modification_time}), $archive->{archive}; - } - - printf "\e[0;34m%s: \e[0m", "Enter ID to restore (Enter to skip)"; - my $selection = <STDIN>; - return if !defined($selection); - chomp $selection; - - return unless ($selection =~ /^\d+$/ && defined(${$archives}[$selection])); - return ${$archives}[$selection]; -} - -sub select_archive_timespec { - my $archives = shift; - my $timespec = shift; - - my $seconds = timespec_to_seconds($timespec); - if (!defined($seconds)) { - say STDERR "Error: Invalid time specification"; - return; - } - - my $target_timestamp = time - $seconds; - - debug("Searching for newest archive that contains a copy before ", format_timestamp($target_timestamp)); - - for my $archive (reverse @$archives) { - if ($archive->{modification_time} < $target_timestamp) { - return $archive; - } - } - - return; -} - -sub format_timestamp { - my $timestamp = shift; - - state $timezone = DateTime::TimeZone->new( name => 'local' ); - my $dt = DateTime->from_epoch(epoch => $timestamp, time_zone => $timezone); - return $dt->strftime("%a. %F %H:%M:%S %z"); -} - -sub timespec_to_seconds { - my $timespec = shift; - - if ($timespec =~ m/^(?<value>[0-9.]+)(?<unit>.+)$/) { - my $value = $+{value}; - my $unit = $+{unit}; - - my %factors = ( - s => 1, - second => 1, - seconds => 1, - minute => 60, - minutes => 60, - h => 60*60, - hour => 60*60, - hours => 60*60, - d => 60*60*24, - day => 60*60*24, - days => 60*60*24, - m => 60*60*24*31, - month => 60*60*24*31, - months => 60*60*24*31, - y => 60*60*24*365, - year => 60*60*24*365, - years => 60*60*24*365, - ); - - if (exists($factors{$unit})) { - return $value * $factors{$unit}; - } - } - - return; -} - -sub restore { - my $path = shift; - my $archive = shift; - my $destination = shift; - - $destination = Helper::untaint($destination, qr(.*)); - $path = Helper::untaint($path, qr(.*)); - my $archive_name = Helper::untaint_archive_name($archive->{archive}); - - printf "Restoring %s to %s from archive %s\n", $path, $destination, $archive->{archive}; - - my $basename = basename($path); - my $components_to_strip =()= $path =~ /\//g; - - debug(sprintf("CWD is %s", getcwd())); - debug(sprintf("Changing CWD to %s", $destination)); - mkdir($destination) unless -d $destination; - chdir($destination) or die "Failed to chdir: $!"; - - my $final_destination = abs_path($basename); - $final_destination = Helper::untaint($final_destination, qr(.*)); - debug("Removing ".$final_destination); - File::Path::remove_tree($final_destination); - system(qw(borg extract -v --strip-components), $components_to_strip, "::".$archive_name, $path); -} - -sub get_cache_dir { - return "$Settings::cache_path_base/v2"; -} - -sub get_cache_path { - my $item = shift; - return get_cache_dir()."/$item"; -} - -sub get_temp_path { - my $item = shift; - - state $tempdir_obj = File::Temp->newdir(); - - my $tempdir = $tempdir_obj->dirname; - - return $tempdir."/".$item; -} - -sub add_path_to_hash { - my $hash = shift; - my $path = shift; - my $time = shift; - - my @components = split /\//, $path; - - my $node = $hash; - - if ($path eq ".") { - if ($time > $$node[1]) { - $$node[1] = $time; - } - return; - } - - # each node is an arrayref of the format [$hashref_of_children, $mtime] - # $hashref_of_children is undef if there are no children - for my $component (@components) { - if (!defined($$node[0]->{$component})) { - $$node[0]->{$component} = [undef, $time]; - } - # update mtime per child - if ($time > $$node[1]) { - $$node[1] = $time; - } - $node = $$node[0]->{$component}; - } -} - -sub get_missing_items { - my $have = shift; - my $want = shift; - - my $ret = []; - - for my $item (@$want) { - my $exists = any { $_ eq $item } @$have; - push @$ret, $item if not $exists; - } - - return $ret; -} - -sub handle_removed_archives { - my $db = shift; - my $borg_archives = shift; - - my $start = Time::HiRes::gettimeofday(); - - my $existing_archives = $db->get_archive_names(); - - # TODO this name is slightly confusing, but it works as expected and - # returns elements that are in the previous list, but missing in the new - # one - my $remove_archives = get_missing_items($borg_archives, $existing_archives); - - if (@$remove_archives) { - for my $archive (@$remove_archives) { - debug(sprintf("Removing archive %s", $archive)); - $db->begin_work; - $db->remove_archive($archive); - $db->commit; - $db->vacuum; - } - - my $end = Time::HiRes::gettimeofday(); - debug(sprintf("Removing archives finished after: %.5fs", $end - $start)); - } -} - -sub sanitize_db_data { - my $data = shift; - - my @ret; - - for my $item (@$data) { - if (defined($item)) { - push @ret, $item + 0; - } else { - push @ret, undef; - } - } - - return \@ret; -} - -sub handle_added_archives { - my $db = shift; - my $borg_archives = shift; - - my $archives = $db->get_archive_names(); - my $add_archives = get_missing_items($archives, $borg_archives); - - for my $archive (@$add_archives) { - my $start = Time::HiRes::gettimeofday(); - my $lookuptable = [{}, 0]; - - debug(sprintf("Adding archive %s", $archive)); - - my $proc = start [qw(borg list --list-format), '{isomtime} {path}{NEWLINE}', "::".$archive], ">pipe", \*OUT; - while (<OUT>) { - # roll our own parsing of timestamps for speed since we will be parsing - # a huge number of lines here - # example timestamp: "Wed, 2016-01-27 10:31:59" - if (m/^.{4} (?<year>....)-(?<month>..)-(?<day>..) (?<hour>..):(?<minute>..):(?<second>..) (?<path>.+)$/) { - my $time = POSIX::mktime($+{second},$+{minute},$+{hour},$+{day},$+{month}-1,$+{year}-1900); - #debug(sprintf("Adding path %s with time %s", $+{path}, $time)); - add_path_to_hash($lookuptable, $+{path}, $time); - } - } - $proc->finish() or die "borg list returned $?"; - - debug(sprintf("Finished parsing borg output after %.5fs. Adding to db", Time::HiRes::gettimeofday - $start)); - - $db->begin_work; - $db->add_archive_name($archive); - my $archive_id = $db->get_archive_id($archive); - save_node($db, $archive_id, undef, $lookuptable); - $db->commit; - $db->vacuum; - - my $end = Time::HiRes::gettimeofday(); - debug(sprintf("Adding archive finished after: %.5fs", $end - $start)); - } -} - -sub build_archive_cache { - my $borg_archives = borg_list(); - my $db_path = get_cache_path('archives.db'); - - # ensure the cache directory exists - mkpath(get_cache_dir(), {mode => oct(700)}); - - if (! -f $db_path) { - debug("Creating initial database"); - my $db = open_db($db_path); - $db->initialize_db(); - } - - my $db = open_db($db_path); - - my $archives = $db->get_archive_names(); - - debug(sprintf("Found %d archives in db", scalar(@$archives))); - - handle_removed_archives($db, $borg_archives); - handle_added_archives($db, $borg_archives); - - if ($opts{debug}) { - debug(sprintf("DB contains information for %d archives in %d rows", scalar(@{$db->get_archive_names()}), $db->get_archive_row_count())); - } -} - -sub open_db { - my $db_path = shift; - - return DB->new($db_path); -} - -sub save_node { - my $db = shift; - my $archive_id = shift; - my $prefix = shift; - my $node = shift; - - for my $child (keys %{$$node[0]}) { - my $path; - $path = $prefix."/" if defined($prefix); - $path .= $child; - - my $time = $$node[0]->{$child}[1]; - $db->add_path($archive_id, $path, $time); - - save_node($db, $archive_id, $path, $$node[0]->{$child}); - } -} - -sub get_mtime_from_lookuptable { - my $lookuptable = shift; - my $path = shift; - - my @components = split /\//, $path; - my $node = $lookuptable; - - for my $component (@components) { - $node = $$node[0]->{$component}; - if (!defined($node)) { - return; - } - } - return $$node[1]; -} - -sub update_cache { - debug("Checking if cache is complete"); - build_archive_cache(); - debug("Cache complete"); -} - -sub main { - # untaint PATH because we only expect this to run as root - $ENV{PATH} = Helper::untaint($ENV{PATH}, qr(.*)); - - $ENV{BORG_REPO} = $Settings::borg_repo unless $Settings::borg_repo eq ""; - - Getopt::Long::Configure ("bundling"); - GetOptions(\%opts, "help|h", "debug", "update-cache|u", "destination|d=s", "time|t=s") or pod2usage(2); - pod2usage(0) if $opts{help}; - - if ($opts{"update-cache"}) { - update_cache(); - return 0; - } - - pod2usage(-verbose => 0) if (@ARGV== 0); - - my @paths = @ARGV; - - my $path; - my $timespec; - my $destination; - - $path = $ARGV[0]; - - if (defined($opts{destination})) { - $destination = $opts{destination}; - } - - if (defined($opts{time})) { - $timespec = $opts{time}; - } - - if (@ARGV > 1) { - say STDERR "Error: Too many arguments"; - exit(1); - } - - my $canon_path = File::Spec->canonpath($path); - my $abs_path = abs_path($canon_path); - if (!defined($abs_path)) { - say STDERR "Error: Failed to resolve path to absolute path: $canon_path: $!"; - say STDERR "Make sure that all parts of the path, except the last one, exist."; - exit(1); - } - - if (!defined($destination)) { - $destination = dirname($abs_path); - } - my $backup_path = $abs_path; - for my $backup_prefix (@Settings::backup_prefixes) { - if ($backup_path =~ m/$backup_prefix->{regex}/) { - $backup_path =~ s/$backup_prefix->{regex}/$backup_prefix->{replacement}/; - last; - } - } - - debug("Asked to restore $backup_path to $destination"); - - my $archives = find_archives($backup_path); - - my $selected_archive; - - if (defined($timespec)) { - $selected_archive = select_archive_timespec($archives, $timespec); - } else { - $selected_archive = user_select_archive($archives); - } - - if (!defined($selected_archive)) { - say STDERR "Error: No archive selected or selection invalid"; - return 1; - } - - restore($backup_path, $selected_archive, $destination); - - return 0; -} - -exit main(); - -package DB; -use strict; -use warnings; -use Data::Dumper; -use DBI; - -sub new { - my $class = shift; - my $db_path = shift; - - my $self = {}; - bless $self, $class; - - $self->_open_db($db_path); - - return $self; -} - -sub _open_db { - my $self = shift; - my $dbfile = shift; - - $self->{dbh} = DBI->connect("dbi:SQLite:dbname=$dbfile","","", {RaiseError => 1, Taint => 1}); - $self->{dbh}->do("PRAGMA cache_size=-1024000"); - $self->{dbh}->do("PRAGMA strict=ON"); -} - -sub initialize_db { - my $self = shift; - - $self->{dbh}->do('create table `files` (`path` text, primary key (`path`)) without rowid;'); - $self->{dbh}->do('create table `archives` (`archive_name` text unique);'); -} - -sub get_archive_names { - my $self = shift; - - my @ret; - - my $st = $self->{dbh}->prepare("select `archive_name` from `archives`;"); - $st->execute(); - while (my $result = $st->fetchrow_hashref) { - push @ret, $result->{archive_name}; - } - return \@ret; -} - -sub get_archive_row_count { - my $self = shift; - - my $st = $self->{dbh}->prepare("select count(*) count from `files`;"); - $st->execute(); - my $result = $st->fetchrow_hashref; - return $result->{count}; -} - -sub add_archive_name { - my $self = shift; - my $archive = shift; - - $archive = Helper::untaint_archive_name($archive); - - my $st = $self->{dbh}->prepare('insert into `archives` (`archive_name`) values (?);'); - $st->execute($archive); - - $self->_add_column_to_table("files", $archive); -} - -sub _add_column_to_table { - my $self = shift; - my $table = shift; - my $column = shift; - - my $st = $self->{dbh}->prepare('alter table `'.$table.'` add column `'._prefix_archive_id($column).'` integer;'); - $st->execute(); -} - -sub remove_archive { - my $self = shift; - my $archive = shift; - - $archive = Helper::untaint_archive_name($archive); - - my $archive_id = $self->get_archive_id($archive); - - my @keep_archives = grep {$_ ne $archive;} @{$self->get_archive_names()}; - - $self->{dbh}->do('create table `files_new` (`path` text, primary key (`path`)) without rowid;'); - for my $archive (@keep_archives) { - $self->_add_column_to_table("files_new", $archive); - } - - my @columns_to_copy = map {'`'._prefix_archive_id($_).'`'} @keep_archives; - @columns_to_copy = ('`path`', @columns_to_copy); - $self->{dbh}->do('insert into `files_new` select '.join(',', @columns_to_copy).' from files'); - - $self->{dbh}->do('drop table `files`'); - - $self->{dbh}->do('alter table `files_new` rename to `files`'); - - my $st = $self->{dbh}->prepare('delete from `archives` where `archive_name` = ?;'); - $st->execute($archive); -} - -sub _prefix_archive_id { - my $archive = shift; - - $archive = Helper::untaint_archive_name($archive); - - return 'timestamp-'.$archive; -} - -sub get_archive_id { - my $self = shift; - my $archive = shift; - - return _prefix_archive_id($archive); -} - -sub get_archives_for_path { - my $self = shift; - my $path = shift; - - my $st = $self->{dbh}->prepare('select * from `files` where `path` = ?;'); - $st->execute(Helper::untaint($path, qr(.*))); - - my @ret; - - my $result = $st->fetchrow_hashref; - my $archives = $self->get_archive_names(); - - for my $archive (@$archives) { - my $archive_id = $self->get_archive_id($archive); - my $timestamp = $result->{$archive_id}; - - push @ret, { - modification_time => $timestamp, - archive => $archive, - }; - } - - return \@ret; -} - - -sub add_path { - my $self = shift; - my $archive_id = shift; - my $path = shift; - my $time = shift; - - my $st = $self->{dbh}->prepare_cached('insert or ignore into `files` (`path`, `'.$archive_id.'`) - values(?, ?)'); - $st->execute($path, $time); - - $st = $self->{dbh}->prepare_cached('update files set `'.$archive_id.'` = ? where `path` = ?'); - $st->execute($time, $path); -} - -sub begin_work { - my $self = shift; - - $self->{dbh}->begin_work(); -} - -sub commit { - my $self = shift; - - $self->{dbh}->commit(); -} - -sub vacuum { - my $self = shift; - - $self->{dbh}->do("vacuum"); -} - -package Helper; - -sub untaint { - my $data = shift; - my $regex = shift; - - $data =~ m/^($regex)$/ or die "Failed to untaint: $data"; - return $1; -} - -sub untaint_archive_name { - my $archive = shift; - return Helper::untaint($archive, qr([a-zA-Z0-9-:+]+)); -} |