Subversion Repositories zfs_utils

Rev

Rev 46 | Go to most recent revision | Show entire file | Ignore whitespace | Details | Blame | Last modification | View Log | RSS feed

Rev 46 Rev 48
Line 32... Line 32...
32
use Data::Dumper;
32
use Data::Dumper;
33
use POSIX qw(strftime);
33
use POSIX qw(strftime);
34
use File::Path qw(make_path);
34
use File::Path qw(make_path);
35
 
35
 
36
# library of ZFS related utility functions
36
# library of ZFS related utility functions
37
# Copyright 2024 Daily Data Inc. <rodo@dailydata.net>
37
# Copyright 2025 Daily Data Inc. <rodo@dailydata.net>
38
 
38
 
39
# currently used for sneakernet scripts, but plans to expand to other ZFS related tasks
39
# currently used for sneakernet scripts, but plans to expand to other ZFS related tasks
40
# functions include:
40
# functions include:
41
#   runCmd: execute a command and return its output
41
#   runCmd: execute a command and return its output (captures exit status in $lastRunError;
-
 
42
#           supports optional stderr merge via $merge_stderr)
42
#   shredFile: securely delete a file using gshred
43
#   shredFile: securely delete a file using gshred (note: not effective on ZFS due to COW)
43
#   logMsg: log messages to a log file and optionally to console
44
#   logMsg: timestamped logging to a file and optionally to console
-
 
45
#   loadConfig: load a YAML configuration file into a hashref; will create the file from a
-
 
46
#           provided default hashref if the file does not exist (uses YAML::XS or YAML::Tiny)
44
#   mountDriveByLabel: find and mount a drive by its GPT label
47
#   mountDriveByLabel: find and mount a drive by its GPT label (supports ufs/msdos; waits
45
#   loadConfig: load a YAML configuration file into a hashref
48
#           for device and creates mountpoint)
-
 
49
#   unmountDriveByLabel: unmount a drive found by GPT label and remove the mountpoint if empty
46
#   mountGeli: decrypt and mount a GELI encrypted ZFS pool
50
#   mountGeli: high level orchestrator to decrypt multiple GELI devices and import/mount a ZFS pool
-
 
51
#   decryptAndMountGeli: attach GELI devices, optionally build a combined key, import the pool
-
 
52
#           and mount ZFS datasets
47
#   makeGeliKey: create a GELI key by XOR'ing a remote binary keyfile and a local hex key
53
#   makeGeliKey: create a GELI key by XOR'ing a remote binary keyfile and a local 256-bit hex key;
48
#   decryptAndMountGeli: decrypt GELI disks and mount the ZFS pool
54
#           writes a 32-byte binary key file with mode 0600
49
#   findGeliDisks: find available disks for GELI/ZFS use
55
#   findGeliDisks: discover candidate disks suitable for GELI on the host
50
#   makeReplicateCommands: create zfs send commands for replication based on snapshot lists
56
#   makeReplicateCommands: build zfs send/receive command lists from snapshot lists and prior status
-
 
57
#   sendReport: helper to deliver replication reports (email/file) — exported for scripts to implement
-
 
58
#   fatalError: helper to log a fatal condition and die (convenience wrapper)
-
 
59
#   getDirectoryList: utility to list directory contents with optional filters
-
 
60
#   cleanDirectory: safe directory cleaning utility used by snapshot pruning helpers
-
 
61
#   exported package variables: $logFileName, $displayLogsOnConsole, $lastRunError
-
 
62
 
-
 
63
# v1.0 RWR 20251215
-
 
64
# This is the initial, tested release
51
 
65
 
52
 
66
 
53
# Exported functions and variables
67
# Exported functions and variables
54
 
68
 
55
our @EXPORT_OK = qw(loadConfig shredFile mountDriveByLabel unmountDriveByLabel mountGeli logMsg runCmd makeReplicateCommands sendReport fatalError getDirectoryList cleanDirectory $logFileName $displayLogsOnConsole $lastRunError);
69
our @EXPORT_OK = qw(loadConfig shredFile mountDriveByLabel unmountDriveByLabel mountGeli logMsg runCmd makeReplicateCommands sendReport fatalError getDirectoryList cleanDirectory $logFileName $displayLogsOnConsole $lastRunError);
56
 
70
 
-
 
71
our $VERSION = '1.0';
57
 
72
 
58
our $VERSION = '0.2';
73
# these are variables which affect the flow of the program and are exported so they can be modified by the caller
59
our $logFileName = '/tmp/zfs_utils.log'; # this can be overridden by the caller, and turned off with empty string
74
our $logFileName = '/tmp/zfs_utils.log'; # this can be overridden by the caller, and turned off with empty string
60
our $displayLogsOnConsole = 1; # if non-zero, log messages are also printed to console
75
our $displayLogsOnConsole = 1; # if non-zero, log messages are also printed to console
61
our $merge_stderr = 0; # if set to 1, stderr is captured in runCmd
76
our $merge_stderr = 0; # if set to 1, stderr is captured in runCmd
62
our $lastRunError = 0; # tracks the last error code from runCmd
77
our $lastRunError = 0; # tracks the last error code from runCmd
63
 
78
 
Line 281... Line 296...
281
 
296
 
282
   return $yaml;
297
   return $yaml;
283
}
298
}
284
 
299
 
285
 
300
 
286
# Mount a GELI-encrypted ZFS pool.
301
## Mount a GELI-encrypted ZFS pool (high-level orchestration).
-
 
302
##
-
 
303
## Arguments:
287
# $geliConfig - hashref containing configuration for geli
304
##   $geliConfig - HASHREF containing GELI/ZFS mounting configuration. Expected keys include:
-
 
305
##       poolname        - name of the zpool to import
-
 
306
##       secureKey       - HASHREF with { label, keyfile, path } describing the keyfile disk
-
 
307
##       target          - path where the combined keyfile will be written
-
 
308
##       diskList        - OPTIONAL arrayref of disk device names (eg: ['ada0','ada1'])
-
 
309
##
-
 
310
## Behavior:
-
 
311
##   - Mounts the keyfile disk (using mountDriveByLabel), builds the combined key (makeGeliKey),
-
 
312
##     then calls decryptAndMountGeli to attach geli devices and import/mount the zpool.
-
 
313
##
-
 
314
## Returns:
288
# Returns the pool name on success, empty string on error.
315
##   Pool name (string) on success, empty string on error.
289
sub mountGeli {
316
sub mountGeli {
290
   my $geliConfig = shift;
317
   my $geliConfig = shift;
291
 
318
 
292
   logMsg( "geli config detected, attempting to mount geli disks" );
319
   logMsg( "geli config detected, attempting to mount geli disks" );
293
   # Can't continue at all if no pool name
320
   # Can't continue at all if no pool name
Line 310... Line 337...
310
   my $poolname = decryptAndMountGeli( $geliConfig );
337
   my $poolname = decryptAndMountGeli( $geliConfig );
311
   return $poolname;
338
   return $poolname;
312
                                                
339
                                                
313
}
340
}
314
 
341
 
315
# find all disks which are candidates for use with geli/zfs
342
## Discover disks suitable for GELI/ZFS use on the host.
-
 
343
##
-
 
344
## Returns an array of device names (eg: qw( ada0 ada1 )) that appear free for use.
316
# Grabs all disks on the system, then removes those with partitions
345
## The routine collects all disks, excludes disks with existing partitions and those
317
# and those already used in zpools.
346
## referenced by active zpools.
318
sub findGeliDisks {
347
sub findGeliDisks {
319
   logMsg("Finding available disks for GELI/ZFS use");
348
   logMsg("Finding available disks for GELI/ZFS use");
320
   # get all disks in system
349
   # get all disks in system
321
   my %allDisks = map{ chomp $_ ; $_ => 1 } runCmd( "geom disk list | grep 'Geom name:' | rev | cut -d' ' -f1 | rev" );
350
   my %allDisks = map{ chomp $_ ; $_ => 1 } runCmd( "geom disk list | grep 'Geom name:' | rev | cut -d' ' -f1 | rev" );
322
   # get the disks with partitions
351
   # get the disks with partitions
Line 334... Line 363...
334
 
363
 
335
   # return only the disks which are free (value 1)
364
   # return only the disks which are free (value 1)
336
   return grep{ $allDisks{$_} == 1 } keys %allDisks;
365
   return grep{ $allDisks{$_} == 1 } keys %allDisks;
337
}
366
}
338
 
367
 
-
 
368
## Decrypt GELI-encrypted disks and import/mount the ZFS pool.
-
 
369
##
-
 
370
## Arguments:
-
 
371
##   $geliConfig - HASHREF expected to contain:
-
 
372
##       poolname - zpool name to import
-
 
373
##       target   - path to the combined GELI keyfile created by makeGeliKey
-
 
374
##       diskList - OPTIONAL arrayref of disk device names (if omitted, findGeliDisks() is used)
-
 
375
##
-
 
376
## Behavior:
-
 
377
##   - Ensures the pool is not already imported
339
## Decrypt each GELI disk from $geliConfig->{'diskList'} using the keyfile,
378
##   - Attaches (geli attach) each supplied disk using the keyfile
340
## then import and mount the ZFS pool specified in $geliConfig->{'poolname'}.
379
##   - Attempts to import the specified pool and runs `zfs mount -a` to mount datasets
341
##
380
##
-
 
381
## Returns:
342
## Returns the pool name on success, empty on error.
382
##   Pool name (string) on success; empty string on failure.
343
sub decryptAndMountGeli {
383
sub decryptAndMountGeli {
344
   my ($geliConfig) = shift;
384
   my ($geliConfig) = shift;
345
   
385
   
346
   # if no list of disks provided, try to find them
386
   # if no list of disks provided, try to find them
347
   $geliConfig->{'diskList'} //= [ findGeliDisks() ];
387
   $geliConfig->{'diskList'} //= [ findGeliDisks() ];
Line 417... Line 457...
417
   return $poolname;
457
   return $poolname;
418
}
458
}
419
 
459
 
420
## Create a GELI key by XOR'ing a remote binary keyfile and a local key (hex string).
460
## Create a GELI key by XOR'ing a remote binary keyfile and a local key (hex string).
421
##
461
##
422
## Arguments:
462
## Expected input (via $geliConfig HASHREF):
423
##   $remote_keyfile - path to binary keyfile (32 bytes)
463
##   $geliConfig->{secureKey}->{path} - directory where the remote keyfile resides
-
 
464
##   $geliConfig->{secureKey}->{keyfile} - filename of the remote 32-byte binary key
424
##   $localKeyHexOrPath - hex string (64 hex chars) or path to file containing hex
465
##   $geliConfig->{localKey} - 64-hex char string OR path to a file containing the hex
425
##   $target - path to write the resulting 32-byte binary key
466
##   $geliConfig->{target} - path to write the resulting 32-byte binary key
-
 
467
##
-
 
468
## Behavior:
-
 
469
##   - Reads 32 bytes from the remote binary key
-
 
470
##   - Reads/cleans the 64-hex local key and converts it to 32 bytes
-
 
471
##   - XORs the two 32-byte buffers and writes the 32-byte result to $target with mode 0600
426
##
472
##
427
## Returns true on success, dies on fatal error.
473
## Returns: 1 on success. Dies on unrecoverable errors.
428
sub makeGeliKey {
474
sub makeGeliKey {
429
   my ( $geliConfig ) = @_;
475
   my ( $geliConfig ) = @_;
430
 
476
 
431
   $geliConfig->{secureKey}->{keyfile} //= '';
477
   $geliConfig->{secureKey}->{keyfile} //= '';
432
   $geliConfig->{localKey} //= '';
478
   $geliConfig->{localKey} //= '';
Line 663... Line 709...
663
      $reportConfig->{subject} //= 'Replication Report from ' . `hostname`;
709
      $reportConfig->{subject} //= 'Replication Report from ' . `hostname`;
664
      sendEmailReport( $reportConfig->{email}, $reportConfig->{subject}, $message, $logFile );
710
      sendEmailReport( $reportConfig->{email}, $reportConfig->{subject}, $message, $logFile );
665
   }
711
   }
666
}
712
}
667
 
713
 
668
# Copy the report log file to the specified mount point.
714
## Copy the report log file to a mounted target drive.
-
 
715
##
-
 
716
## Arguments:
669
# $logFile is the path to the log file to copy.
717
##   $logFile    - path to the log file to copy (must exist)
670
# $mountPoint is the mount point of the target drive.
718
##   $mountPoint - mount point of the target drive (must be a directory)
-
 
719
##
-
 
720
## Behavior:
671
# Does nothing if log file or mount point are invalid.
721
##   - Copies the log file into the root of $mountPoint using File::Copy::copy
-
 
722
##   - Logs success/failure via logMsg
672
sub copyReportToDrive {
723
sub copyReportToDrive {
673
   my ( $logFile, $mountPoint ) = @_;
724
   my ( $logFile, $mountPoint ) = @_;
674
   return unless defined $logFile && -e $logFile;
725
   return unless defined $logFile && -e $logFile;
675
   return unless defined $mountPoint && -d $mountPoint;
726
   return unless defined $mountPoint && -d $mountPoint;
676
 
727
 
Line 680... Line 731...
680
   unless ( copy( $logFile, $targetFile ) ) {
731
   unless ( copy( $logFile, $targetFile ) ) {
681
      logMsg( "Could not copy report log file to target drive: $!" );
732
      logMsg( "Could not copy report log file to target drive: $!" );
682
   }
733
   }
683
}
734
}
684
 
735
 
685
# Send an email report with the contents of the log file.
736
## Send an email report with an attached log body.
-
 
737
##
-
 
738
## Arguments:
686
# $to is the recipient email address.
739
##   $to      - recipient email address (string)
687
# $subject is the email subject.
740
##   $subject - subject line (string)
-
 
741
##   $message - optional message body (string)
-
 
742
##   $logFile - optional path to log file whose contents will be appended to the email body
-
 
743
##
-
 
744
## Behavior:
-
 
745
##   - Opens /usr/sbin/sendmail -t and writes a simple plain-text email including the
688
# $logFile is the path to the log file to send.
746
##     supplied message and the contents of $logFile (if present).
689
# Does nothing if any parameter is invalid.
747
##   - Logs failures to open sendmail or read the log file.
690
sub sendEmailReport {
748
sub sendEmailReport {
691
   my ( $to, $subject, $message, $logFile ) = @_;
749
   my ( $to, $subject, $message, $logFile ) = @_;
692
   return unless defined $to && $to ne '';
750
   return unless defined $to && $to ne '';
693
   $subject //= 'Sneakernet Replication Report from ' . `hostname`;
751
   $subject //= 'Sneakernet Replication Report from ' . `hostname`;
694
   $message //= '';
752
   $message //= '';
Line 717... Line 775...
717
   };
775
   };
718
 
776
 
719
   close $mailfh;
777
   close $mailfh;
720
}  
778
}  
721
 
779
 
722
# Get all file names (not directories) from a directory
780
## Return list of regular files in a directory (non-recursive).
-
 
781
##
-
 
782
## Arguments:
723
# $dirname is directory to scan
783
##   $dirname - directory to scan
-
 
784
##
724
# returns arrayref
785
## Returns: ARRAYREF of full-path filenames on success, 0 on error (matching prior behavior).
725
sub getDirectoryList {
786
sub getDirectoryList {
726
   my $dirname = shift;
787
   my $dirname = shift;
727
   opendir( my $dh, $dirname ) || return 0;
788
   opendir( my $dh, $dirname ) || return 0;
728
   # get all file names, but leave directories alone
789
   # get all file names, but leave directories alone
729
   my @files = map{ $dirname . "/$_" } grep { -f "$dirname/$_" } readdir($dh);
790
   my @files = map{ $dirname . "/$_" } grep { -f "$dirname/$_" } readdir($dh);
730
   closedir $dh;
791
   closedir $dh;
731
   return \@files;
792
   return \@files;
732
}
793
}
733
 
794
 
734
# clean all files from a directory, but not any subdirectories
795
## Remove all regular files from the specified directory (non-recursive).
-
 
796
##
-
 
797
## Arguments:
-
 
798
##   $dirname - directory to clean
-
 
799
##
-
 
800
## Behavior:
-
 
801
##   - Calls getDirectoryList to obtain files and unlinks each file. Directories are left untouched.
-
 
802
##   - Logs the cleanup operation via logMsg.
-
 
803
##
-
 
804
## Returns: 1 on completion. Note: individual unlink failures are currently reported via warn.
735
sub cleanDirectory {
805
sub cleanDirectory {
736
   my $dirname = shift;
806
   my $dirname = shift;
737
   logMsg( "Cleaning up $dirname of all files" );
807
   logMsg( "Cleaning up $dirname of all files" );
738
   my $files = getDirectoryList( $dirname );
808
   my $files = getDirectoryList( $dirname );
739
   # clean up a directory
809
   # clean up a directory
Line 741... Line 811...
741
      unlink $file or warn "Could not unlink $file: #!\n";
811
      unlink $file or warn "Could not unlink $file: #!\n";
742
   }
812
   }
743
   return 1;
813
   return 1;
744
}
814
}
745
 
815
 
746
# handle fatal error by logging message and dying
816
## Handle a fatal error: log, optionally run a cleanup routine, then die.
-
 
817
##
-
 
818
## Arguments:
747
# message - message to log, and also sent via email if applicable
819
##   $message        - string message describing the fatal condition
748
# config - configuration hashref (optional)
820
##   $config         - OPTIONAL configuration HASHREF (passed to cleanupRoutine)
749
# cleanupRoutine - code reference to cleanup routine (optional)
821
##   $cleanupRoutine - OPTIONAL CODE ref to run prior to dying; will be called as
-
 
822
##                     $cleanupRoutine->($config, $message)
-
 
823
##
-
 
824
## Behavior:
750
# if cleanupRoutine is provided, it will be called before dying passing it the config hashref
825
##   - Logs the fatal message via logMsg, runs the cleanup code if provided (errors in the cleanup
-
 
826
##     are logged), then terminates the process via die.
751
sub fatalError {
827
sub fatalError {
752
   my ( $message, $config, $cleanupRoutine ) = @_;
828
   my ( $message, $config, $cleanupRoutine ) = @_;
753
   logMsg( "FATAL ERROR: $message" );
829
   logMsg( "FATAL ERROR: $message" );
754
   if ( defined $cleanupRoutine && ref $cleanupRoutine eq 'CODE' ) {
830
   if ( defined $cleanupRoutine && ref $cleanupRoutine eq 'CODE' ) {
755
      logMsg( "Running cleanup routine before fatal error" );
831
      logMsg( "Running cleanup routine before fatal error" );