Subversion Repositories zfs_utils

Rev

Rev 31 | Rev 34 | Go to most recent revision | Details | Compare with Previous | Last modification | View Log | RSS feed

Rev Author Line No. Line
24 rodolico 1
package ZFS_Utils;
2
 
3
use strict;
4
use warnings;
5
use Exporter 'import';
6
use Data::Dumper;
7
use POSIX qw(strftime);
8
use File::Path qw(make_path);
9
 
25 rodolico 10
our @EXPORT_OK = qw(loadConfig shredFile mountDriveByLabel mountGeli logMsg runCmd makeReplicateCommands $logFileName $displayLogsOnConsole);
24 rodolico 11
 
12
 
13
our $VERSION = '0.1';
14
our $logFileName = '/tmp/zfs_utils.log'; # this can be overridden by the caller, and turned off with empty string
15
our $displayLogsOnConsole = 1;
27 rodolico 16
our $merge_stderr = 0; # if set to 1, stderr is captured in runCmd
24 rodolico 17
 
25 rodolico 18
# Execute a command and return its output.
19
# If called in scalar context, returns the full output as a single string.
20
# If called in list context, returns the output split into lines.
21
# If $merge_stderr is true (default), stderr is merged into stdout (only for scalar commands).
22
# returns empty string or empty list on failure and logs failure message.
23
sub runCmd {
33 rodolico 24
   my $cmd = join( ' ', @_ );
25 rodolico 25
   $merge_stderr = 1 unless defined $merge_stderr;
26
   my $output = '';
27
 
33 rodolico 28
#   if (ref $cmd eq 'ARRAY') {
29
#      # Execute without a shell (safer). Note: stderr is not merged in this path.
30
#      logMsg( 'Running command [' . join( ' ', @$cmd ) . ']');
31
#      open my $fh, '-|', @{$cmd} or do {
32
#         logMsg("runCmd: failed to exec '@{$cmd}': $!");
33
#         return wantarray ? () : '';
34
#      };
35
#      local $/ = undef;
36
#      $output = <$fh>;
37
#      close $fh;
38
#   } else {
25 rodolico 39
      # Scalar command runs via the shell; optionally merge stderr into stdout.
33 rodolico 40
      logMsg( "Running command [$cmd]" );
41
      $cmd .= ' 2>&1' if $merge_stderr;
42
      $output = `$cmd`;
43
if ($? == -1) {
44
    logMsg( "failed to execute: $!");
45
    return ''
46
}
47
elsif ($? & 127) { # fatal error, exit program
48
    logMsg( sprintf( "child died with signal %d, %s coredump\n", ($? & 127),  ($? & 128) ? 'with' : 'without' ) );
49
    die;
50
}
51
else {
52
    logMsg( sprintf( "child exited with value %d\n", $? >> 8 ) );
53
}
25 rodolico 54
 
33 rodolico 55
#   }
25 rodolico 56
   $output //= '';
57
 
58
   if (wantarray) {
59
      return $output eq '' ? () : split(/\n/, $output);
60
   } else {
61
      return $output;
62
   }
63
}
64
 
24 rodolico 65
# this calls gshred which will overwrite the file 3 times, then
66
# remove it.
67
# NOTE: this will not work on ZFS, since ZFS is CopyOnWrite (COW)
68
# so assuming file is on something without COW (ramdisk, UFS, etc)
69
sub shredFile {
70
   my $filename = shift;
71
   `/usr/local/bin/gshred -u -f -s 32 $filename` if -e $filename;
72
}
73
 
74
sub logMsg {
75
    my $msg = shift;
76
    my $filename = shift // $logFileName;
77
    my $timeStampFormat = shift // '%Y-%m-%d %H:%M:%S';
78
    my $timestamp = strftime($timeStampFormat, localtime());
79
    if (defined $filename && $filename ne '' ) {
80
       open my $logfh, '>>', $filename or die "Could not open log file $filename: $!\n";
81
       print $logfh "$timestamp\t$msg\n";
82
       close $logfh;
83
    }
84
    print "$timestamp\t$msg\n" if ($displayLogsOnConsole);
85
}
86
 
87
# find a drive by it's label by scanning /dev/gpt/ for $timeout seconds.
88
# If the drive is found, mount it on mountPath and return the mountPath.
89
# If not found, return empty string.
90
sub mountDriveByLabel {
33 rodolico 91
   my ($label, $mountPath, $timeout, $checkEvery, $filesystem, $devPath ) = @_;
24 rodolico 92
   unless ($label) {
93
      logMsg("mountDriveByLabel: No label provided");
94
      return '';
95
   }
96
   unless ( $label =~ /^[a-zA-Z0-9_\-]+$/ ) {
97
      logMsg("mountDriveByLabel: Invalid label '$label'");
98
      return '';
99
   }
100
 
101
   logMsg("mountDriveByLabel: Looking for drive with label '$label'");
102
   # default to /mnt/label if not provided
103
   $mountPath //= "/mnt/$label"; # this is where we'll mount it if we find it
33 rodolico 104
   $devPath //= "/dev/gpt/";
105
   $label = "$devPath$label"; #  this is where FreeBSD puts gpt labeled drives
106
   $filesystem //= 'ufs'; # default to mounting ufs
31 rodolico 107
   # drive already mounted, just return the path
108
   return $mountPath if ( runCmd( "mount | grep '$mountPath'" ) );
24 rodolico 109
   # default to 10 minutes (600 seconds) if not provided
110
   $timeout //= 600;
111
   # default to checking every minute if not provided
31 rodolico 112
   $checkEvery //= 15;
24 rodolico 113
   # wait up to $timeout seconds for device to appear, checking every 10 seconds
114
   while ( $timeout > 0 ) {
115
      if ( -e "$label" ) {
116
         last;
117
      } else {
118
         sleep $checkEvery;
119
         $timeout -= $checkEvery;
31 rodolico 120
         print "Waiting for drive labeled $label\n";
24 rodolico 121
      }
122
    }
123
    # if we found it, mount and return mount path
124
    if ( -e "$label" ) {
125
       # ensure mount point
126
       unless ( -d $mountPath || make_path($mountPath) ) {
127
         logMsg("Failed to create $mountPath: $!");
128
         return '';
129
       }
130
       # mount device (let mount detect filesystem)
33 rodolico 131
       unless ( runCmd( "mount -t $filesystem $label $mountPath" ) ) {
24 rodolico 132
         logMsg("Failed to mount $label on $mountPath: $!");
133
         return '';
134
       }
135
       return $mountPath;
136
    } else {
137
       return '';
138
    }
139
}
140
 
141
## Load a YAML configuration file into a hashref.
142
## If the file does not exist, and a default hashref is provided,
143
## create the file by dumping the default to YAML, then return the default.
144
sub loadConfig {
145
    my ($filename, $default) = @_;
146
 
147
    # If no filename was provided, return default or empty hashref
148
    die "No filename provided to loadConfig\n" unless defined $filename;
149
 
150
    # If file doesn't exist but a default hashref was provided, try to
151
    # create the file by dumping the default to YAML, then return the default.
152
    unless (-e $filename) {
153
      logMsg("Config file $filename does not exist. Creating it with default values.");
154
      if ($default && ref $default eq 'HASH') {
155
         my $wrote = 0;
156
         eval {
157
               require YAML::XS;
158
               YAML::XS->import();
159
               YAML::XS::DumpFile($filename, $default);
160
               $wrote = 1;
161
               1;
162
         } or do {
163
               eval {
164
                  require YAML::Tiny;
165
                  YAML::Tiny->import();
166
                  my $yt = YAML::Tiny->new($default);
167
                  $yt->write($filename);
168
                  $wrote = 1;
169
                  1;
170
               } or do {
171
                  logMsg("No YAML writer available (YAML::XS or YAML::Tiny). Could not create $filename");
172
               };
173
         };
174
 
175
         die "Failed to write default config to $filename:$!\n" unless $wrote;
176
        }
177
 
178
        # No default provided; nothing to create
179
        return {};
180
    }
181
 
182
    my $yaml;
183
 
184
    # Try YAML::XS first, fall back to YAML::Tiny
185
    eval {
186
        require YAML::XS;
187
        YAML::XS->import();
188
        $yaml = YAML::XS::LoadFile($filename);
189
        logMsg("using YAML::XS to load $filename");
190
        1;
191
    } or do {
192
        eval {
193
            require YAML::Tiny;
194
            YAML::Tiny->import();
195
            $yaml = YAML::Tiny->read($filename);
196
            $yaml = $yaml->[0] if $yaml;  # YAML::Tiny returns an arrayref of documents
197
            logMsg("using YAML::Tiny to load $filename");
198
            1;
199
        } or do {
200
            logMsg("No YAML parser installed (YAML::XS or YAML::Tiny). Skipping config load from $filename");
201
            return ($default && ref $default eq 'HASH') ? $default : {};
202
        };
203
    };
204
    # Ensure we have a hashref
205
    die "Config file $filename did not produce a HASH.\n" unless (defined $yaml && ref $yaml eq 'HASH');
206
 
207
    return $yaml;
208
}
209
 
210
 
211
 
212
sub mountGeli {
213
   my $geliConfig = shift;
30 rodolico 214
   unless ( $geliConfig->{'localKey'} ) {
215
      logMsg "Could not find local key in configuration file\n";
24 rodolico 216
      return '';
217
   }
218
   # find the keyfile disk and mount it
219
   my $path = mountDriveByLabel( $geliConfig->{'keydiskname'} );
220
   unless ( $path ne '' and -e "$path/" . $geliConfig->{'keyfile'} ) {
221
      logMsg "Could not find or mount keyfile disk with label: " . $geliConfig->{'keydiskname'} . "\n";
222
      return '';
223
   }
224
   # create the combined geli keyfile in target location
225
   unless ( makeGeliKey( "$path/" . $geliConfig->{'keyfile'}, $geliConfig->{'localKey'}, $geliConfig->{'target'} ) ) {
226
         logMsg "Could not create geli keyfile\n";
227
         return '';
228
      }
229
   # decrypt and mount the geli disks and zfs pool
230
   my $poolname = decryptAndMountGeli( $geliConfig );
231
   return $poolname;
232
 
233
}
234
 
30 rodolico 235
# find all disks which are candidates for use with geli/zfs
236
# Grabs all disks on the system, then removes those with partitions
237
# and those already used in zpools.
238
sub findGeliDisks {
239
   logMsg("Finding available disks for GELI/ZFS use");
240
   # get all disks in system
241
   my %allDisks = map{ chomp $_ ; $_ => 1 } runCmd( "geom disk list | grep 'Geom name:' | rev | cut -d' ' -f1 | rev" );
242
   # get the disks with partitions
243
   my @temp = runCmd( "gpart show -p | grep '^=>'");  # -p prints just the disks without partitions
244
   # remove them from the list
245
   foreach my $disk ( @temp ) {
246
      $allDisks{$1} = 0 if ( $disk =~ m/^=>[\t\s0-9]+([a-z][a-z0-9]+)/ ) ;
247
   }
248
 
249
   # get disk which are currently used for zpools
250
   @temp = runCmd( "zpool status -LP | grep '/dev/'" );
251
   foreach my $disk ( @temp ) {
252
      $allDisks{$1} = 0 if  $disk =~ m|/dev/([a-z]+\d+)|;
253
   }
254
 
255
   # return only the disks which are free (value 1)
256
   return grep{ $allDisks{$_} == 1 } keys %allDisks;
257
}
258
 
24 rodolico 259
## Decrypt each GELI disk from $geliConfig->{'diskList'} using the keyfile,
260
## then import and mount the ZFS pool specified in $geliConfig->{'poolname'}.
261
##
262
## Returns the pool name on success, empty on error.
263
sub decryptAndMountGeli {
264
   my ($geliConfig) = @_;
30 rodolico 265
 
266
   # Can't continue at all if no pool name
24 rodolico 267
   die "No pool name specified in config\n" unless $geliConfig->{'poolname'};
30 rodolico 268
   # if no list of disks provided, try to find them
269
   $geliConfig->{'diskList'} //= findGeliDisks();
270
 
24 rodolico 271
   my $diskList = $geliConfig->{'diskList'};
272
   my $poolname = $geliConfig->{'poolname'};
273
   my $keyfile = $geliConfig->{'target'};
274
   unless ( -e $keyfile ) {
275
      logMsg "GELI keyfile $keyfile does not exist\n";
276
      return '';
277
   }
278
 
279
   my @decrypted_devices;
280
 
281
   # Decrypt each disk in the list
30 rodolico 282
   foreach my $disk (@{$geliConfig->{'diskList'}}) {
24 rodolico 283
      unless ( -e $disk ) {
284
         logMsg "Disk $disk does not exist\n";
285
         return '';
286
      }
287
 
288
      # Derive the decrypted device name (.eli suffix on FreeBSD)
289
      my $decrypted = $disk . '.eli';
290
 
291
      # Decrypt using geli attach with the keyfile
292
      logMsg("Decrypting $disk with keyfile $keyfile");
30 rodolico 293
      if ( my $result = system('geli', 'attach', '-k', $geliConfig->{'target'}, $disk) == 0 ) {
24 rodolico 294
         logMsg "Failed to decrypt $disk (exit $result)\n";
30 rodolico 295
         next; # ignore failed disks and continue to see if we can import the pool
24 rodolico 296
      }
297
 
298
      unless ( -e $decrypted ) {
299
         logMsg "Decrypted device $decrypted does not exist after geli attach\n";
300
         return '';
301
      }
302
      push @decrypted_devices, $decrypted;
303
   }
304
 
305
   # Import the ZFS pool
306
   logMsg("Importing ZFS pool $poolname");
307
   my @import_cmd = ('zpool', 'import');
308
   # If decrypted devices exist, add their directories to -d list
30 rodolico 309
   #foreach my $dev (@decrypted_devices) {
310
   #   my $dir = $dev;
311
   #   $dir =~ s!/[^/]+$!!;  # Remove filename to get directory
312
   #   push @import_cmd, '-d', $dir;
313
   #}
314
 
24 rodolico 315
   push @import_cmd, $poolname;
316
 
317
   my $result = system(@import_cmd);
318
   unless ( $result == 0 ) {
319
      logMsg("Failed to import zfs pool $poolname (exit $result)\n");
320
      return '';
321
   }
322
 
323
   # Mount the ZFS pool (zfs mount -a mounts all filesystems in the pool)
324
   logMsg("Mounting ZFS pool $poolname");
325
   $result = system('zfs', 'mount', '-a');
326
   unless ( $result == 0 ) {
327
      logMsg("Failed to mount zfs pool $poolname (exit $result)\n");
328
      return '';
329
   }
330
 
331
   logMsg("Successfully decrypted and mounted pool $poolname");
332
   return $poolname;
333
}
334
 
335
## Create a GELI key by XOR'ing a remote binary keyfile and a local key (hex string).
336
##
337
## Arguments:
338
##   $remote_keyfile - path to binary keyfile (32 bytes)
339
##   $localKeyHexOrPath - hex string (64 hex chars) or path to file containing hex
340
##   $target - path to write the resulting 32-byte binary key
341
##
342
## Returns true on success, dies on fatal error.
343
sub makeGeliKey {
344
   my ($remote_keyfile, $localKeyHexOrPath, $target) = @_;
345
 
346
   die "remote keyfile not provided" unless defined $remote_keyfile;
347
   die "local key not provided" unless defined $localKeyHexOrPath;
348
   die "target not provided" unless defined $target;
349
 
350
   die "Remote keyfile $remote_keyfile does not exist\n" unless -e $remote_keyfile;
351
 
352
   # Read remote binary key
353
   open my $rh, '<:raw', $remote_keyfile or die "Unable to open $remote_keyfile: $!\n";
354
   my $rbuf;
355
   my $read = read($rh, $rbuf, 32);
356
   close $rh;
357
   die "Failed to read 32 bytes from $remote_keyfile (got $read)\n" unless defined $read && $read == 32;
358
 
359
   # Get local hex string (either direct string or file contents)
360
   my $hex;
361
   if (-e $localKeyHexOrPath) {
362
      open my $lh, '<', $localKeyHexOrPath or die "Unable to open local key file $localKeyHexOrPath: $!\n";
363
      local $/ = undef;
364
      $hex = <$lh>;
365
      close $lh;
366
   } else {
367
      $hex = $localKeyHexOrPath;
368
   }
369
   # clean hex (remove whitespace/newlines and optional 0x)
370
   $hex =~ s/0x//g;
371
   $hex =~ s/[^0-9a-fA-F]//g;
372
 
373
   die "Local key must be 64 hex characters (256-bit)\n" unless length($hex) == 64;
374
 
375
   my $lbuf = pack('H*', $hex);
376
   die "Local key decoded to unexpected length " . length($lbuf) . "\n" unless length($lbuf) == 32;
377
 
378
   # XOR the two buffers
379
   my $out = '';
380
   for my $i (0 .. 31) {
381
      $out .= chr( ord(substr($rbuf, $i, 1)) ^ ord(substr($lbuf, $i, 1)) );
382
   }
383
 
384
   # Ensure target directory exists
385
   my ($vol, $dirs, $file) = ($target =~ m{^(/?)(.*/)?([^/]+)$});
386
   if ($dirs) {
387
      my $dir = $dirs;
388
      $dir =~ s{/$}{};
389
      unless (-d $dir) {
390
         require File::Path;
391
         File::Path::make_path($dir) or die "Failed to create directory $dir: $!\n";
392
      }
393
   }
394
 
395
   # Write out binary key and protect permissions
396
   open my $oh, '>:raw', $target or die "Unable to open $target for writing: $!\n";
397
   print $oh $out or die "Failed to write to $target: $!\n";
398
   close $oh;
399
   chmod 0600, $target;
400
 
401
   return 1;
402
}
403
 
25 rodolico 404
sub makeReplicateCommands {
405
   my ($sourceSnapsRef, $statusRef, $newStatusRef) = @_;
406
   $sourceSnapsRef ||= [];
407
   $statusRef     ||= [];
408
   $newStatusRef  ||= [];
409
 
410
   # parse snapshots: each line is expected to have snapshot fullname as first token: pool/fs@snap ...
411
   my %snaps_by_fs;
412
   foreach my $line (@$sourceSnapsRef) {
413
      next unless defined $line && $line =~ /\S/;
414
      my ($tok) = split /\s+/, $line;
415
      next unless $tok && $tok =~ /@/;
416
      my ($fs, $snap) = split /@/, $tok, 2;
417
      push @{ $snaps_by_fs{$fs} }, $snap;
418
   }
419
 
420
   # nothing to do
421
   return [] unless keys %snaps_by_fs;
422
 
423
   # figure root filesystem: first snapshot line's fs is the requested root
424
   my ($first_line) = grep { defined $_ && $_ =~ /\S/ } @$sourceSnapsRef;
425
   my ($root_fs) = $first_line ? (split(/\s+/, $first_line))[0] =~ /@/ ? (split(/@/, (split(/\s+/, $first_line))[0]))[0] : undef : undef;
426
   $root_fs ||= (sort keys %snaps_by_fs)[0];
427
 
428
   # helper: find last status entry for a filesystem (status lines contain full snapshot names pool/fs@snap)
429
   my %last_status_for;
430
   for my $s (@$statusRef) {
431
      next unless $s && $s =~ /@/;
432
      my ($fs, $snap) = split /@/, $s, 2;
433
      $last_status_for{$fs} = $snap;    # later entries override earlier ones -> last occurrence kept
434
   }
435
 
436
   # build per-filesystem "from" and "to"
437
   my %from_for;
438
   my %to_for;
439
   foreach my $fs (keys %snaps_by_fs) {
440
      my $arr = $snaps_by_fs{$fs};
441
      next unless @$arr;
442
      $to_for{$fs} = $arr->[-1];
443
      $from_for{$fs} = $last_status_for{$fs};    # may be undef -> full send required
444
   }
445
 
446
   # decide if we can do a single recursive send:
447
   # condition: all 'to' snapshot names are identical
448
   my %to_names = map { $_ => 1 } values %to_for;
449
   my $single_to_name = (keys %to_names == 1) ? (keys %to_names)[0] : undef;
450
 
31 rodolico 451
   my %commands;
25 rodolico 452
 
453
   if ($single_to_name) {
454
      # check whether any from is missing
455
      my @from_values = map { $from_for{$_} } sort keys %from_for;
456
      my $any_from_missing = grep { !defined $_ } @from_values;
457
      my %from_names = map { $_ => 1 } grep { defined $_ } @from_values;
458
      my $single_from_name = (keys %from_names == 1) ? (keys %from_names)[0] : undef;
459
 
460
      if ($any_from_missing) {
461
         # full recursive send from root
31 rodolico 462
         $commands{'root_fs'} = sprintf('zfs send -R %s@%s', $root_fs, $single_to_name);
25 rodolico 463
      }
464
      elsif ($single_from_name) {
31 rodolico 465
         # incremental recursive send, but don't do it if they are the same
466
         $commands{$root_fs} = sprintf('zfs send -R -I %s@%s %s@%s',
467
                           $root_fs, $single_from_name, $root_fs, $single_to_name)
468
                           unless $single_from_name eq $single_to_name;
25 rodolico 469
      }
470
      else {
471
         # from snapshots differ across children -> fall back to per-filesystem sends
472
         foreach my $fs (sort keys %to_for) {
473
            my $to  = $to_for{$fs};
474
            my $from = $from_for{$fs};
475
            if ($from) {
31 rodolico 476
               # if from and to are different, add it
477
               $commands{$fs} = sprintf('zfs send -I %s@%s %s@%s', $fs, $from, $fs, $to)
478
                  unless $from eq $to;
25 rodolico 479
            } else {
31 rodolico 480
               $commands{$fs} = sprintf('zfs send %s@%s', $fs, $to);
25 rodolico 481
            }
482
         }
483
      }
484
 
485
      # update new status: record newest snap for every filesystem
486
      foreach my $fs (keys %to_for) {
487
         push @$newStatusRef, sprintf('%s@%s', $fs, $to_for{$fs});
488
      }
489
   } else {
490
      # not all children share same newest snap -> per-filesystem sends
491
      foreach my $fs (sort keys %to_for) {
492
         my $to  = $to_for{$fs};
493
         my $from = $from_for{$fs};
494
         if ($from) {
31 rodolico 495
            $commands{$fs} = sprintf('zfs send -I %s@%s %s@%s', $fs, $from, $fs, $to);
25 rodolico 496
         } else {
31 rodolico 497
            $commands{$fs} = sprintf('zfs send %s@%s', $fs, $to);
25 rodolico 498
         }
499
         push @$newStatusRef, sprintf('%s@%s', $fs, $to);
500
      }
501
   }
502
 
503
   # return arrayref of commands (caller can iterate or join with pipes)
31 rodolico 504
   return \%commands;
25 rodolico 505
}
506
 
507
 
24 rodolico 508
1;