App-Yabsm
view release on metacpan or search on metacpan
lib/App/Yabsm/Backup/SSH.pm view on Meta::CPAN
# Author: Nicholas Hubbard
# WWW: https://github.com/NicholasBHubbard/yabsm
# License: MIT
# Provides the &do_ssh_backup subroutine, which performs a single
# ssh_backup. This is a top-level subroutine that is directly scheduled to be
# run by the daemon.
use strict;
use warnings;
use v5.16.3;
package App::Yabsm::Backup::SSH;
use App::Yabsm::Tools qw( :ALL );
use App::Yabsm::Config::Query qw( :ALL );
use App::Yabsm::Snapshot qw(delete_snapshot
sort_snapshots
is_snapshot_name
);
use App::Yabsm::Backup::Generic qw(take_bootstrap_snapshot
the_local_bootstrap_snapshot
take_tmp_snapshot
bootstrap_lock_file
create_bootstrap_lock_file
);
use Net::OpenSSH;
use Carp qw(confess);
use File::Basename qw(basename);
use Exporter 'import';
our @EXPORT_OK = qw(do_ssh_backup
do_ssh_backup_bootstrap
maybe_do_ssh_backup_bootstrap
the_remote_bootstrap_snapshot
new_ssh_conn
ssh_system_or_die
check_ssh_backup_config_or_die
);
####################################
# SUBROUTINES #
####################################
sub do_ssh_backup {
# Perform a $tframe ssh_backup for $ssh_backup.
arg_count_or_die(4, 4, @_);
my $ssh = shift;
my $ssh_backup = shift;
my $tframe = shift;
my $config_ref = shift;
# We can't do a backup if the bootstrap process is currently being performed.
if (bootstrap_lock_file($ssh_backup, 'ssh', $config_ref)) {
return undef;
}
$ssh //= new_ssh_conn($ssh_backup, $config_ref);
check_ssh_backup_config_or_die($ssh, $ssh_backup, $config_ref);
my $tmp_snapshot = take_tmp_snapshot($ssh_backup, 'ssh', $tframe, $config_ref);
my $bootstrap_snapshot = maybe_do_ssh_backup_bootstrap($ssh, $ssh_backup, $config_ref);
my $backup_dir = ssh_backup_dir($ssh_backup, $tframe, $config_ref);
my $backup_dir_base = ssh_backup_dir($ssh_backup, undef, $config_ref);
ssh_system_or_die(
$ssh,
# This is why we need the remote user to have write permission on the
# backup dir
"if ! [ -d '$backup_dir' ]; then mkdir '$backup_dir'; fi"
);
ssh_system_or_die(
$ssh,
{stdin_file => ['-|', "sudo -n btrfs send -p '$bootstrap_snapshot' '$tmp_snapshot'"]},
"sudo -n btrfs receive '$backup_dir'"
);
# The tmp snapshot is irrelevant now
delete_snapshot($tmp_snapshot);
# Delete old backups
my @remote_backups = grep { is_snapshot_name($_) } ssh_system_or_die($ssh, "ls -1 '$backup_dir'");
map { chomp $_ ; $_ = "$backup_dir/$_" } @remote_backups;
# sorted from newest to oldest
@remote_backups = sort_snapshots(\@remote_backups);
my $num_backups = scalar @remote_backups;
my $to_keep = ssh_backup_timeframe_keep($ssh_backup, $tframe, $config_ref);
# There is 1 more backup than should be kept because we just performed a
# backup.
if ($num_backups == $to_keep + 1) {
my $oldest = pop @remote_backups;
ssh_system_or_die($ssh, "sudo -n btrfs subvolume delete '$oldest'");
}
# We havent reached the backup quota yet so we don't delete anything
elsif ($num_backups <= $to_keep) {
;
}
# User changed their settings to keep less backups than they were keeping
# prior.
else {
for (; $num_backups > $to_keep; $num_backups--) {
my $oldest = pop @remote_backups;
ssh_system_or_die($ssh, "sudo -n btrfs subvolume delete '$oldest'");
}
}
return "$backup_dir/" . basename($tmp_snapshot);
}
sub do_ssh_backup_bootstrap {
# Perform the bootstrap phase of an incremental backup for $ssh_backup.
arg_count_or_die(3, 3, @_);
my $ssh = shift;
my $ssh_backup = shift;
my $config_ref = shift;
if (bootstrap_lock_file($ssh_backup, 'ssh', $config_ref)) {
return undef;
}
# The lock file will be deleted when $lock_fh goes out of scope (uses File::Temp).
my $lock_fh = create_bootstrap_lock_file($ssh_backup, 'ssh', $config_ref);
$ssh //= new_ssh_conn($ssh_backup, $config_ref);
if (my $local_boot_snap = the_local_bootstrap_snapshot($ssh_backup, 'ssh', $config_ref)) {
delete_snapshot($local_boot_snap);
}
if (my $remote_boot_snap = the_remote_bootstrap_snapshot($ssh, $ssh_backup, $config_ref)) {
ssh_system_or_die($ssh, "sudo -n btrfs subvolume delete '$remote_boot_snap'");
}
my $local_boot_snap = take_bootstrap_snapshot($ssh_backup, 'ssh', $config_ref);
my $remote_backup_dir = ssh_backup_dir($ssh_backup, undef, $config_ref);
ssh_system_or_die(
$ssh,
{stdin_file => ['-|', "sudo -n btrfs send '$local_boot_snap'"]},
"sudo -n btrfs receive '$remote_backup_dir'"
);
return $local_boot_snap;
}
sub maybe_do_ssh_backup_bootstrap {
# Like &do_ssh_backup_bootstrap but only perform the bootstrap if it hasn't
# been performed yet.
arg_count_or_die(3, 3, @_);
my $ssh = shift;
my $ssh_backup = shift;
my $config_ref = shift;
$ssh //= new_ssh_conn($ssh_backup, $config_ref);
my $local_boot_snap = the_local_bootstrap_snapshot($ssh_backup, 'ssh', $config_ref);
my $remote_boot_snap = the_remote_bootstrap_snapshot($ssh, $ssh_backup, $config_ref);
unless ($local_boot_snap && $remote_boot_snap) {
$local_boot_snap = do_ssh_backup_bootstrap($ssh, $ssh_backup, $config_ref);
}
return $local_boot_snap;
}
sub the_remote_bootstrap_snapshot {
# Return the remote bootstrap snapshot for $ssh_backup if it exists and
# return undef otherwise. Die if we find multiple bootstrap snapshots.
arg_count_or_die(3, 3, @_);
my $ssh = shift;
my $ssh_backup = shift;
my $config_ref = shift;
$ssh //= new_ssh_conn($ssh_backup, $config_ref);
my $remote_backup_dir = ssh_backup_dir($ssh_backup, undef, $config_ref);
my @boot_snaps = grep { is_snapshot_name($_, ONLY_BOOTSTRAP => 1) } ssh_system_or_die($ssh, "ls -1 -a '$remote_backup_dir'");
map { chomp $_ ; $_ = "$remote_backup_dir/$_" } @boot_snaps;
if (0 == @boot_snaps) {
return undef;
}
elsif (1 == @boot_snaps) {
return $boot_snaps[0];
}
else {
my $ssh_dest = ssh_backup_ssh_dest($ssh_backup, $config_ref);
die "yabsm: ssh error: $ssh_dest: found multiple remote bootstrap snapshots in '$remote_backup_dir'\n";
}
}
sub new_ssh_conn {
# Return a Net::OpenSSH connection object to $ssh_backup's ssh destination or
# die if a connection cannot be established.
arg_count_or_die(2, 2, @_);
my $ssh_backup = shift;
my $config_ref = shift;
my $home_dir = (getpwuid $<)[7]
or die q(yabsm: error: user ').scalar(getpwuid $<).q(' does not have a home directory to hold SSH keys);
my $pub_key = "$home_dir/.ssh/id_ed25519.pub";
my $priv_key = "$home_dir/.ssh/id_ed25519";
unless (-f $pub_key) {
my $username = getpwuid $<;
die "yabsm: error: cannot not find '$username' users SSH public SSH key '$pub_key'\n";
}
unless (-f $priv_key) {
my $username = getpwuid $<;
die "yabsm: error: cannot not find '$username' users private SSH key '$priv_key'\n";
}
my $ssh_dest = ssh_backup_ssh_dest($ssh_backup, $config_ref);
my $ssh = Net::OpenSSH->new(
$ssh_dest,
master_opts => [ '-q' ], # quiet
batch_mode => 1, # Key based auth only
ctl_dir => '/tmp',
remote_shell => 'sh',
);
if ($ssh->error) {
die "yabsm: ssh error: $ssh_dest: cannot establish SSH connection: ".$ssh->error."\n";
}
return $ssh;
}
sub ssh_system_or_die {
# Like Net::OpenSSH::capture but die if the command fails.
arg_count_or_die(2, 3, @_);
my $ssh = shift;
my %opts = ref $_[0] eq 'HASH' ? %{ shift() } : ();
my $cmd = shift;
wantarray ? my @out = $ssh->capture(\%opts, $cmd) : my $out = $ssh->capture(\%opts, $cmd);
if ($ssh->error) {
my $host = $ssh->get_host;
die "yabsm: ssh error: $host: remote command '$cmd' failed:".$ssh->error."\n";
}
return wantarray ? @out : $out;
}
sub check_ssh_backup_config_or_die {
# Ensure that the $ssh_backup's ssh destination server is configured
# properly and die with useful errors if not.
arg_count_or_die(3, 3, @_);
my $ssh = shift;
my $ssh_backup = shift;
my $config_ref = shift;
$ssh //= new_ssh_conn($ssh_backup, $config_ref);
my $remote_backup_dir = ssh_backup_dir($ssh_backup, undef, $config_ref);
my $ssh_dest = ssh_backup_ssh_dest($ssh_backup, $config_ref);
my (undef, $stderr) = $ssh->capture2(qq(
ERRORS=''
add_error() {
if [ -z "\$ERRORS" ]; then
ERRORS="yabsm: ssh error: $ssh_dest: \$1"
else
ERRORS="\${ERRORS}\nyabsm: ssh error: $ssh_dest: \$1"
fi
}
HAVE_BTRFS=true
if ! which btrfs >/dev/null 2>&1; then
HAVE_BTRFS=false
add_error "btrfs-progs not in '\$(whoami)'s path"
fi
if [ "\$HAVE_BTRFS" = true ] && ! sudo -n btrfs --help >/dev/null 2>&1; then
add_error "user '\$(whoami)' does not have root sudo access to btrfs-progs"
fi
if ! [ -d '$remote_backup_dir' ] || ! [ -r '$remote_backup_dir' ] || ! [ -w '$remote_backup_dir' ]; then
add_error "no directory '$remote_backup_dir' that is readable+writable by user '\$(whoami)'"
else
if [ "\$HAVE_BTRFS" = true ] && ! btrfs property list '$remote_backup_dir' >/dev/null 2>&1; then
add_error "'$remote_backup_dir' is not a directory residing on a btrfs filesystem"
fi
fi
if [ -n '\$ERRORS' ]; then
1>&2 printf %s "\$ERRORS"
exit 1
else
exit 0
fi
));
if ($stderr) {
die "$stderr\n";
}
return 1;
}
1;
( run in 0.738 second using v1.01-cache-2.11-cpan-ceb78f64989 )