#! /usr/bin/env perl # Simplified BSD License (FreeBSD License) # # Copyright (c) 2026, Daily Data Inc. # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, this # list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. use strict; use warnings; use Data::Dumper; use Getopt::Long qw(GetOptions); Getopt::Long::Configure("bundling"); my $VERSION = '1.0.0'; my $config; # ==================== HELPER FUNCTIONS ==================== # runCommand: Print and optionally execute shell commands # Prints all commands for visibility # Only executes if $config->{force} is true (dry-run by default) sub runCommand { my $command = shift; print "$command\n"; `$command` if $config->{force}; } # ==================== MAIN SCRIPT ==================== # create an encrypted ZFS pool using geli # Configuration can be set via command-line options or defaults # Default configuration $config = { 'datadrives' => [], # list of drives to use for data 'dedupdrives' => [], # list of drives to use for deduplication (optional) 'raidlevel' => 'mirror', # Raid level: mirror, raidz, raidz2, raidz3 'pool' => 'backup', # pool name to create. Destroyed if it exists 'poolsettings' => "atime=off compress=gzip-9 volmode=full", # settings to apply after creation 'keyfile' => undef, # path to combined geli key file 'force' => 0, # if true, actually execute commands (default: dry-run) }; # Parse command-line options GetOptions( 'datadrives=s@' => sub { $config->{datadrives} = [split(/,/, $_[1])] }, 'dedupdrives=s@' => sub { $config->{dedupdrives} = [split(/,/, $_[1])] }, 'raidlevel=s' => \$config->{raidlevel}, 'pool=s' => \$config->{pool}, 'poolsettings=s' => \$config->{poolsettings}, 'keyfile=s' => \$config->{keyfile}, 'force|f' => \$config->{force}, 'version|V' => sub { print "makeEncryptedZPool version $VERSION\n"; exit 0; }, 'help|h' => sub { print "Usage: $0 [OPTIONS]\n"; print "\nRequired:\n"; print " --keyfile FILE Path to combined GELI key file\n"; print "\nPool Configuration:\n"; print " --datadrives DRIVES Comma-separated list of data drives (e.g., da0,da1)\n"; print " Can be specified multiple times or comma-separated\n"; print " Default: none\n"; print " --dedupdrives DRIVES Comma-separated list of dedup drives (optional)\n"; print " Default: none\n"; print " --raidlevel LEVEL RAID level: mirror, raidz, raidz2, raidz3\n"; print " Default: mirror\n"; print " --pool NAME Pool name to create\n"; print " Default: backup\n"; print " --poolsettings OPTS ZFS pool settings (quoted string)\n"; print " Default: \"atime=off compress=gzip-9 volmode=full\"\n"; print "\nExecution:\n"; print " --force, -f Actually execute commands (default: dry-run)\n"; print " Without this flag, commands are only printed\n"; print "\nOther:\n"; print " --version, -V Display version number\n"; print " --help, -h Display this help message\n"; print "\nExamples:\n"; print " $0 --keyfile /tmp/keys/master.key --pool mybackup --raidlevel raidz2 --datadrives da0,da1,da2,da3\n"; print " $0 --keyfile /tmp/keys/master.key --datadrives da0,da1 --dedupdrives da4,da5\n"; exit 0; }, ) or die "Error in command line arguments\n"; # ==================== CONFIGURATION VALIDATION ==================== # Check for keyfile (from option or command line argument) # Allow both --keyfile option and positional argument for backward compatibility $config->{keyfile} //= shift @ARGV; die "No combined key file provided. Use --keyfile FILE or pass as argument\n" unless defined $config->{keyfile}; # Enable dedup if dedupdrives are specified # When dedup drives are configured, add dedup=on to pool settings $config->{poolsettings} .= ' dedup=on' if @{$config->{dedupdrives}}; # Combine all drives (data + dedup) into a single list for GELI operations my @alldrives = (@{$config->{datadrives}}, @{$config->{dedupdrives}}); my $alldrives = join(' ', @alldrives); # ==================== CLEANUP EXISTING RESOURCES ==================== # Destroy the pool if it already exists # This ensures a clean slate before creating the new pool if ( `zpool list -H | grep '^$config->{pool}'` ) { &runCommand( "zpool destroy $config->{pool}" ); } # Detach and clear any existing GELI-encrypted disks # This prevents conflicts with previously encrypted devices my @disks = `geli status | grep '^da' | cut -d' ' -f1 2>/dev/null`; chomp @disks; if ( join ' ', @disks ) { # Prompt user before destroying existing GELI configurations warn "Detaching and clearing existing geli disks: " . join( ' ', @disks ) . "\nPress Enter to continue..."; <>; my $temp = join( ' ', @disks ); &runCommand( "geli detach $temp" ); # detach first (disconnect from kernel) $temp =~ s/\.eli//g; # remove .eli extension to get base device names &runCommand( "geli clear $temp"); # clear GELI metadata from devices } # Discover all partitioned drives on the system # Build a hash for quick lookup of which drives have partitions my @partitionedDrives = `gpart list -a | grep 'Geom name:' | rev | cut -d' ' -f1 | rev`; chomp @partitionedDrives; my %partitionedDrives = map{ $_=>1 } @partitionedDrives; # Remove any partitions from drives we plan to use # This ensures drives start with a clean partition table foreach my $drive ( @{$config->{datadrives}}, @{$config->{dedupdrives}} ) { &runCommand( "gpart destroy -F $drive" ) if $partitionedDrives{$drive}; } # ==================== GELI INITIALIZATION ==================== # Initialize GELI encryption on all drives # -e AES-XTS: Use AES-XTS encryption algorithm (best for full disk encryption) # -l 256: Use 256-bit key length for strong encryption # -s 4096: Use 4096-byte sector size (modern disk standard) # -K: Specify keyfile (non-interactive) # -P: Don't use a passphrase (keyfile only) &runCommand( "geli init -e AES-XTS -l 256 -s 4096 -K $config->{keyfile} -P " . join( ' ', @alldrives ) ); # Configure GELI to trim deleted data (important for SSDs) # -t: Enable TRIM support for secure deletion and SSD longevity &runCommand( "geli configure -t " . join( ' ', @alldrives ) ); # Attach encrypted devices to make them available for use # Creates /dev/device.eli for each encrypted device # -k: Use keyfile for attachment # -p: Don't prompt for passphrase &runCommand( "geli attach -k $config->{keyfile} -p " . join( ' ', @alldrives ) ); # ==================== ZFS POOL CREATION ==================== # Create the ZFS pool with specified RAID level # -f: Force creation (overwrite any existing data) # Builds vdev specification: # - Data vdev: uses raidlevel (mirror/raidz/raidz2) with data drives # - Dedup vdev: if configured, creates separate mirrored vdev for dedup table &runCommand( "zpool create -f $config->{pool} $config->{raidlevel} " . join( ' ', map{ $_ . '.eli' } @{$config->{datadrives}}) . ( @{$config->{dedupdrives}} ? ' dedup mirror ' . join( ' ', map{ $_ . '.eli' } @{$config->{dedupdrives}}) : '' ) ); # Apply pool settings (compression, atime, dedup, etc.) &runCommand( "zfs set $config->{poolsettings} $config->{pool}" ); 1;