File Coverage

blib/lib/App/Yabsm/Backup/SSH.pm
Criterion Covered Total %
statement 32 143 22.3
branch 0 36 0.0
condition 0 18 0.0
subroutine 11 18 61.1
pod 0 7 0.0
total 43 222 19.3


line stmt bran cond sub pod time code
1             # Author: Nicholas Hubbard
2             # WWW: https://github.com/NicholasBHubbard/yabsm
3             # License: MIT
4              
5             # Provides the &do_ssh_backup subroutine, which performs a single
6             # ssh_backup. This is a top-level subroutine that is directly scheduled to be
7             # run by the daemon.
8              
9 2     2   408 use strict;
  2         7  
  2         45  
10 2     2   7 use warnings;
  2         3  
  2         41  
11 2     2   15 use v5.16.3;
  2         4  
12              
13             package App::Yabsm::Backup::SSH;
14              
15 2     2   309 use App::Yabsm::Tools qw( :ALL );
  2         3  
  2         246  
16 2     2   384 use App::Yabsm::Config::Query qw( :ALL );
  2         9  
  2         648  
17 2         108 use App::Yabsm::Snapshot qw(delete_snapshot
18             sort_snapshots
19             is_snapshot_name
20 2     2   639 );
  2         2  
21 2         128 use App::Yabsm::Backup::Generic qw(take_bootstrap_snapshot
22             the_local_bootstrap_snapshot
23             take_tmp_snapshot
24             bootstrap_lock_file
25             create_bootstrap_lock_file
26 2     2   654 );
  2         4  
27              
28 2     2   1530 use Net::OpenSSH;
  2         52616  
  2         93  
29 2     2   15 use Carp qw(confess);
  2         3  
  2         84  
30 2     2   11 use File::Basename qw(basename);
  2         2  
  2         72  
31              
32 2     2   9 use Exporter 'import';
  2         3  
  2         2201  
33             our @EXPORT_OK = qw(do_ssh_backup
34             do_ssh_backup_bootstrap
35             maybe_do_ssh_backup_bootstrap
36             the_remote_bootstrap_snapshot
37             new_ssh_conn
38             ssh_system_or_die
39             check_ssh_backup_config_or_die
40             );
41              
42             ####################################
43             # SUBROUTINES #
44             ####################################
45              
46             sub do_ssh_backup {
47              
48             # Perform a $tframe ssh_backup for $ssh_backup.
49              
50 0     0 0   arg_count_or_die(4, 4, @_);
51              
52 0           my $ssh = shift;
53 0           my $ssh_backup = shift;
54 0           my $tframe = shift;
55 0           my $config_ref = shift;
56              
57             # We can't do a backup if the bootstrap process is currently being performed.
58 0 0         if (bootstrap_lock_file($ssh_backup, 'ssh', $config_ref)) {
59 0           return undef;
60             }
61              
62 0   0       $ssh //= new_ssh_conn($ssh_backup, $config_ref);
63              
64 0           check_ssh_backup_config_or_die($ssh, $ssh_backup, $config_ref);
65              
66 0           my $tmp_snapshot = take_tmp_snapshot($ssh_backup, 'ssh', $tframe, $config_ref);
67 0           my $bootstrap_snapshot = maybe_do_ssh_backup_bootstrap($ssh, $ssh_backup, $config_ref);
68 0           my $backup_dir = ssh_backup_dir($ssh_backup, $tframe, $config_ref);
69 0           my $backup_dir_base = ssh_backup_dir($ssh_backup, undef, $config_ref);
70              
71 0           ssh_system_or_die(
72             $ssh,
73             # This is why we need the remote user to have write permission on the
74             # backup dir
75             "if ! [ -d '$backup_dir' ]; then mkdir '$backup_dir'; fi"
76             );
77              
78 0           ssh_system_or_die(
79             $ssh,
80             {stdin_file => ['-|', "sudo -n btrfs send -p '$bootstrap_snapshot' '$tmp_snapshot'"]},
81             "sudo -n btrfs receive '$backup_dir'"
82             );
83              
84             # The tmp snapshot is irrelevant now
85 0           delete_snapshot($tmp_snapshot);
86              
87             # Delete old backups
88              
89 0           my @remote_backups = grep { is_snapshot_name($_) } ssh_system_or_die($ssh, "ls -1 '$backup_dir'");
  0            
90 0           map { chomp $_ ; $_ = "$backup_dir/$_" } @remote_backups;
  0            
  0            
91             # sorted from newest to oldest
92 0           @remote_backups = sort_snapshots(\@remote_backups);
93              
94 0           my $num_backups = scalar @remote_backups;
95 0           my $to_keep = ssh_backup_timeframe_keep($ssh_backup, $tframe, $config_ref);
96              
97             # There is 1 more backup than should be kept because we just performed a
98             # backup.
99 0 0         if ($num_backups == $to_keep + 1) {
    0          
100 0           my $oldest = pop @remote_backups;
101 0           ssh_system_or_die($ssh, "sudo -n btrfs subvolume delete '$oldest'");
102             }
103             # We havent reached the backup quota yet so we don't delete anything
104             elsif ($num_backups <= $to_keep) {
105             ;
106             }
107             # User changed their settings to keep less backups than they were keeping
108             # prior.
109             else {
110 0           for (; $num_backups > $to_keep; $num_backups--) {
111 0           my $oldest = pop @remote_backups;
112 0           ssh_system_or_die($ssh, "sudo -n btrfs subvolume delete '$oldest'");
113             }
114             }
115              
116 0           return "$backup_dir/" . basename($tmp_snapshot);
117             }
118              
119             sub do_ssh_backup_bootstrap {
120              
121             # Perform the bootstrap phase of an incremental backup for $ssh_backup.
122              
123 0     0 0   arg_count_or_die(3, 3, @_);
124              
125 0           my $ssh = shift;
126 0           my $ssh_backup = shift;
127 0           my $config_ref = shift;
128              
129 0 0         if (bootstrap_lock_file($ssh_backup, 'ssh', $config_ref)) {
130 0           return undef;
131             }
132              
133             # The lock file will be deleted when $lock_fh goes out of scope (uses File::Temp).
134 0           my $lock_fh = create_bootstrap_lock_file($ssh_backup, 'ssh', $config_ref);
135              
136 0   0       $ssh //= new_ssh_conn($ssh_backup, $config_ref);
137              
138 0 0         if (my $local_boot_snap = the_local_bootstrap_snapshot($ssh_backup, 'ssh', $config_ref)) {
139 0           delete_snapshot($local_boot_snap);
140             }
141 0 0         if (my $remote_boot_snap = the_remote_bootstrap_snapshot($ssh, $ssh_backup, $config_ref)) {
142 0           ssh_system_or_die($ssh, "sudo -n btrfs subvolume delete '$remote_boot_snap'");
143             }
144              
145 0           my $local_boot_snap = take_bootstrap_snapshot($ssh_backup, 'ssh', $config_ref);
146              
147 0           my $remote_backup_dir = ssh_backup_dir($ssh_backup, undef, $config_ref);
148              
149 0           ssh_system_or_die(
150             $ssh,
151             {stdin_file => ['-|', "sudo -n btrfs send '$local_boot_snap'"]},
152             "sudo -n btrfs receive '$remote_backup_dir'"
153             );
154              
155 0           return $local_boot_snap;
156             }
157              
158             sub maybe_do_ssh_backup_bootstrap {
159              
160             # Like &do_ssh_backup_bootstrap but only perform the bootstrap if it hasn't
161             # been performed yet.
162              
163 0     0 0   arg_count_or_die(3, 3, @_);
164              
165 0           my $ssh = shift;
166 0           my $ssh_backup = shift;
167 0           my $config_ref = shift;
168              
169 0   0       $ssh //= new_ssh_conn($ssh_backup, $config_ref);
170              
171 0           my $local_boot_snap = the_local_bootstrap_snapshot($ssh_backup, 'ssh', $config_ref);
172 0           my $remote_boot_snap = the_remote_bootstrap_snapshot($ssh, $ssh_backup, $config_ref);
173              
174 0 0 0       unless ($local_boot_snap && $remote_boot_snap) {
175 0           $local_boot_snap = do_ssh_backup_bootstrap($ssh, $ssh_backup, $config_ref);
176             }
177              
178 0           return $local_boot_snap;
179             }
180              
181             sub the_remote_bootstrap_snapshot {
182              
183             # Return the remote bootstrap snapshot for $ssh_backup if it exists and
184             # return undef otherwise. Die if we find multiple bootstrap snapshots.
185              
186 0     0 0   arg_count_or_die(3, 3, @_);
187              
188 0           my $ssh = shift;
189 0           my $ssh_backup = shift;
190 0           my $config_ref = shift;
191              
192 0   0       $ssh //= new_ssh_conn($ssh_backup, $config_ref);
193              
194 0           my $remote_backup_dir = ssh_backup_dir($ssh_backup, undef, $config_ref);
195 0           my @boot_snaps = grep { is_snapshot_name($_, ONLY_BOOTSTRAP => 1) } ssh_system_or_die($ssh, "ls -1 -a '$remote_backup_dir'");
  0            
196 0           map { chomp $_ ; $_ = "$remote_backup_dir/$_" } @boot_snaps;
  0            
  0            
197              
198 0 0         if (0 == @boot_snaps) {
    0          
199 0           return undef;
200             }
201             elsif (1 == @boot_snaps) {
202 0           return $boot_snaps[0];
203             }
204             else {
205 0           my $ssh_dest = ssh_backup_ssh_dest($ssh_backup, $config_ref);
206 0           die "yabsm: ssh error: $ssh_dest: found multiple remote bootstrap snapshots in '$remote_backup_dir'\n";
207             }
208             }
209              
210             sub new_ssh_conn {
211              
212             # Return a Net::OpenSSH connection object to $ssh_backup's ssh destination or
213             # die if a connection cannot be established.
214              
215 0     0 0   arg_count_or_die(2, 2, @_);
216              
217 0           my $ssh_backup = shift;
218 0           my $config_ref = shift;
219              
220 0 0         my $home_dir = (getpwuid $<)[7]
221             or die q(yabsm: error: user ').scalar(getpwuid $<).q(' does not have a home directory to hold SSH keys);
222              
223 0           my $pub_key = "$home_dir/.ssh/id_ed25519.pub";
224 0           my $priv_key = "$home_dir/.ssh/id_ed25519";
225              
226 0 0         unless (-f $pub_key) {
227 0           my $username = getpwuid $<;
228 0           die "yabsm: error: cannot not find '$username' users SSH public SSH key '$pub_key'\n";
229             }
230              
231 0 0         unless (-f $priv_key) {
232 0           my $username = getpwuid $<;
233 0           die "yabsm: error: cannot not find '$username' users private SSH key '$priv_key'\n";
234             }
235              
236 0           my $ssh_dest = ssh_backup_ssh_dest($ssh_backup, $config_ref);
237              
238 0           my $ssh = Net::OpenSSH->new(
239             $ssh_dest,
240             master_opts => [ '-q' ], # quiet
241             batch_mode => 1, # Key based auth only
242             ctl_dir => '/tmp',
243             remote_shell => 'sh',
244             );
245              
246 0 0         if ($ssh->error) {
247 0           die "yabsm: ssh error: $ssh_dest: cannot establish SSH connection: ".$ssh->error."\n";
248             }
249              
250 0           return $ssh;
251             }
252              
253             sub ssh_system_or_die {
254              
255             # Like Net::OpenSSH::capture but die if the command fails.
256              
257 0     0 0   arg_count_or_die(2, 3, @_);
258              
259 0           my $ssh = shift;
260 0 0         my %opts = ref $_[0] eq 'HASH' ? %{ shift() } : ();
  0            
261 0           my $cmd = shift;
262              
263 0 0         wantarray ? my @out = $ssh->capture(\%opts, $cmd) : my $out = $ssh->capture(\%opts, $cmd);
264              
265 0 0         if ($ssh->error) {
266 0           my $host = $ssh->get_host;
267 0           die "yabsm: ssh error: $host: remote command '$cmd' failed:".$ssh->error."\n";
268             }
269              
270 0 0         return wantarray ? @out : $out;
271             }
272              
273             sub check_ssh_backup_config_or_die {
274              
275             # Ensure that the $ssh_backup's ssh destination server is configured
276             # properly and die with useful errors if not.
277              
278 0     0 0   arg_count_or_die(3, 3, @_);
279              
280 0           my $ssh = shift;
281 0           my $ssh_backup = shift;
282 0           my $config_ref = shift;
283              
284 0   0       $ssh //= new_ssh_conn($ssh_backup, $config_ref);
285              
286 0           my $remote_backup_dir = ssh_backup_dir($ssh_backup, undef, $config_ref);
287 0           my $ssh_dest = ssh_backup_ssh_dest($ssh_backup, $config_ref);
288              
289 0           my (undef, $stderr) = $ssh->capture2(qq(
290             ERRORS=''
291              
292             add_error() {
293             if [ -z "\$ERRORS" ]; then
294             ERRORS="yabsm: ssh error: $ssh_dest: \$1"
295             else
296             ERRORS="\${ERRORS}\nyabsm: ssh error: $ssh_dest: \$1"
297             fi
298             }
299              
300             HAVE_BTRFS=true
301              
302             if ! which btrfs >/dev/null 2>&1; then
303             HAVE_BTRFS=false
304             add_error "btrfs-progs not in '\$(whoami)'s path"
305             fi
306              
307             if [ "\$HAVE_BTRFS" = true ] && ! sudo -n btrfs --help >/dev/null 2>&1; then
308             add_error "user '\$(whoami)' does not have root sudo access to btrfs-progs"
309             fi
310              
311             if ! [ -d '$remote_backup_dir' ] || ! [ -r '$remote_backup_dir' ] || ! [ -w '$remote_backup_dir' ]; then
312             add_error "no directory '$remote_backup_dir' that is readable+writable by user '\$(whoami)'"
313             else
314             if [ "\$HAVE_BTRFS" = true ] && ! btrfs property list '$remote_backup_dir' >/dev/null 2>&1; then
315             add_error "'$remote_backup_dir' is not a directory residing on a btrfs filesystem"
316             fi
317             fi
318              
319             if [ -n '\$ERRORS' ]; then
320             1>&2 printf %s "\$ERRORS"
321             exit 1
322             else
323             exit 0
324             fi
325             ));
326              
327 0 0         if ($stderr) {
328 0           die "$stderr\n";
329             }
330              
331 0           return 1;
332             }
333              
334             1;