| 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" );
|