#!/usr/bin/env perl

# Standalone GELI decryptor for sneakernet's target-side zpool.
#
# sneakernet's target server keeps its zpool on GELI-encrypted disks and
# unlocks them via mountGeli()/decryptAndMountGeli() in ZFS_Utils.pm, using a
# combined key built by XOR'ing a keyfile from a separate "secure key" disk
# with a locally-held hex value (makeGeliKey()). This script instead takes
# the final key directly as a binary file on the CLI - no sneakernet config,
# drive-mounting, or key-combination code involved - for disaster recovery
# when only the pool's disks and the (already-combined) key are available.
#
# Disk discovery reuses the same heuristic as findGeliDisks() in
# ZFS_Utils.pm: a disk is a candidate if it has no partition table (GELI
# providers are typically raw, unpartitioned disks) and is not already part
# of an imported zpool.
#
# FreeBSD only: geli, geom, gpart, zpool and zfs must be on PATH.

use strict;
use warnings;
use Getopt::Long qw(GetOptions);
use Pod::Usage qw(pod2usage);

my %opt = ( mount => 1 );

GetOptions(
   'key=s'         => \$opt{key},
   'pool=s'        => \$opt{pool},
   'disks=s'       => \$opt{disks},
   'mount!'        => \$opt{mount},
   'force-import'  => \$opt{forceImport},
   'dry-run'       => \$opt{dryrun},
   'verbose+'      => \$opt{verbose},
   'help'          => sub { pod2usage(1) },
) or pod2usage(2);

pod2usage("--key is required")                    unless $opt{key};
pod2usage("--pool is required")                   unless $opt{pool};
pod2usage("Key file '$opt{key}' does not exist")  unless -f $opt{key};

if ( poolIsActive($opt{pool}) ) {
   print "Pool '$opt{pool}' is already imported.\n";
   mountPool($opt{pool}, \%opt) if $opt{mount};
   exit 0;
}

my @disks = $opt{disks} ? split(/,/, $opt{disks}) : @{ findGeliDisks() };
die "No candidate disks found - specify them explicitly with --disks dev1,dev2,...\n" unless @disks;
print "Candidate disks: @disks\n" if $opt{verbose};

my @attached;
for my $disk (@disks) {
   $disk = "/dev/$disk" unless $disk =~ m{^/dev/};
   unless ( -e $disk ) {
      warn "Disk '$disk' does not exist, skipping\n";
      next;
   }

   my $decrypted = "$disk.eli";
   if ( -e $decrypted ) {
      print "Disk '$disk' is already attached ($decrypted)\n" if $opt{verbose};
      push @attached, $decrypted;
      next;
   }

   my $cmd = "geli attach -p -k " . shellQuote($opt{key}) . " " . shellQuote($disk);
   if ( $opt{dryrun} ) {
      print "Would run: $cmd\n";
      next;
   }
   print "Decrypting $disk\n" if $opt{verbose};
   my $out = `$cmd 2>&1`;
   if ( $? != 0 ) {
      warn "Failed to attach '$disk' with the supplied key:\n$out";
      next;
   }
   unless ( -e $decrypted ) {
      warn "geli attach on '$disk' reported success but '$decrypted' does not exist\n";
      next;
   }
   push @attached, $decrypted;
}

if ( $opt{dryrun} ) {
   my $importCmd = "zpool import" . ($opt{forceImport} ? ' -f' : '') . " " . shellQuote($opt{pool});
   print "Would run: $importCmd\n";
   print "Would run: zfs mount -a\n" if $opt{mount};
   exit 0;
}

die "No disks were successfully decrypted; cannot import pool '$opt{pool}'\n" unless @attached;

print "Importing pool '$opt{pool}'" . ($opt{forceImport} ? " (forced)" : '') . "\n" if $opt{verbose};
my $importCmd = "zpool import" . ($opt{forceImport} ? ' -f' : '') . " " . shellQuote($opt{pool});
my $importOut = `$importCmd 2>&1`;
die "Failed to import pool '$opt{pool}':\n$importOut" if $?;

mountPool($opt{pool}, \%opt) if $opt{mount};

print "Pool '$opt{pool}' imported" . ($opt{mount} ? " and mounted.\n" : " (not mounted - re-run without --no-mount to mount it).\n");

## Single-quote a string for safe use in a shell command (same convention as
## shellQuote() in sneakernet/sneakernet).
sub shellQuote {
   my ($s) = @_;
   $s = '' unless defined $s;
   $s =~ s/'/'\\''/g;
   return "'$s'";
}

## True if $pool is already an imported/active zpool.
sub poolIsActive {
   my ($pool) = @_;
   my @out = `zpool list -H -o name 2>/dev/null`;
   chomp @out;
   return scalar grep { $_ eq $pool } @out;
}

## Mount every filesystem in the (now-imported) pool.
sub mountPool {
   my ($pool, $opt) = @_;
   print "Mounting filesystems for '$pool'\n" if $opt->{verbose};
   my $out = `zfs mount -a 2>&1`;
   die "Failed to mount filesystems for pool '$pool':\n$out" if $?;
}

## Port of findGeliDisks() in ZFS_Utils.pm: disks with no partition table and
## not already part of an imported zpool are candidates for a locked GELI
## provider (GELI providers are typically raw, unpartitioned disks).
sub findGeliDisks {
   my %allDisks;
   for my $line (`geom disk list 2>/dev/null`) {
      $allDisks{$1} = 1 if $line =~ /Geom name:\s+(\S+)/;
   }
   for my $line (`gpart show -p 2>/dev/null`) {
      $allDisks{$1} = 0 if $line =~ m/^=>[\t\s0-9]+([a-z][a-z0-9]+)/;
   }
   for my $line (`zpool status -LP 2>/dev/null`) {
      $allDisks{$1} = 0 if $line =~ m{/dev/([a-z]+\d+)};
   }
   return [ grep { $allDisks{$_} == 1 } keys %allDisks ];
}

__END__

=head1 NAME

decryptGeli.pl - standalone GELI decryptor and zpool importer for sneakernet's target server

=head1 SYNOPSIS

 decryptGeli.pl --key key.bin --pool storage

 decryptGeli.pl --key key.bin --pool storage --disks ada1,ada2

 decryptGeli.pl --key key.bin --pool storage --force-import --verbose

Options:

  --key FILE        Binary GELI key file, passed directly to
                     "geli attach -p -k". Required. This is the final,
                     already-combined key - not the two separate inputs
                     (secure-key-disk file + local hex value) that
                     makeGeliKey() in ZFS_Utils.pm XORs together.
  --pool NAME        zpool name to import once disks are decrypted. Required.
  --disks LIST       Comma-separated device names (e.g. ada1,ada2). If
                     omitted, candidate disks are auto-discovered: unpartitioned
                     and not already part of an imported zpool.
  --no-mount         Import the pool but skip "zfs mount -a".
  --force-import     Pass -f to "zpool import" (needed if the pool was not
                     cleanly exported last time, e.g. after a power loss).
  --dry-run          Show what would be run without decrypting/importing/mounting.
  --verbose          Print progress as each step runs.
  --help             Show this message.

=head1 DESCRIPTION

Reverses sneakernet's target-side GELI unlock (mountGeli() /
decryptAndMountGeli() in ZFS_Utils.pm) as an independent script: attach each
candidate disk with C<geli attach -p -k>, import the zpool, and mount it -
without needing sneakernet's YAML config, drive-mounting, or two-factor
key-combination code. Useful when only the pool's physical disks and a
key file are available, for example because sneakernet itself is not
installed or configured on the machine doing the recovery.

=head1 NOTES

=over 4

=item * This does not decrypt sneakernet's transport-drive files (the
per-file C<openssl enc> streams with C<.IV> sidecars) - see
C<decryptTransport.pl> for that.

=item * A disk already attached (its C<.eli> device already exists) is left
alone and reused rather than re-attached.

=item * Auto-discovery can only find disks that are not already partitioned
and not already part of an imported pool - pass C<--disks> explicitly if a
disk needs to be identified some other way.

=back

=cut
