| Line 1... |
Line 1... |
| 1 |
#! /usr/bin/env perl
|
1 |
#! /usr/bin/env perl
|
| 2 |
|
2 |
|
| - |
|
3 |
# Simplified BSD License (FreeBSD License)
|
| - |
|
4 |
#
|
| - |
|
5 |
# Copyright (c) 2025, Daily Data Inc.
|
| - |
|
6 |
# All rights reserved.
|
| - |
|
7 |
#
|
| - |
|
8 |
# Redistribution and use in source and binary forms, with or without
|
| - |
|
9 |
# modification, are permitted provided that the following conditions are met:
|
| - |
|
10 |
#
|
| - |
|
11 |
# 1. Redistributions of source code must retain the above copyright notice, this
|
| - |
|
12 |
# list of conditions and the following disclaimer.
|
| - |
|
13 |
#
|
| - |
|
14 |
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
| - |
|
15 |
# this list of conditions and the following disclaimer in the documentation
|
| - |
|
16 |
# and/or other materials provided with the distribution.
|
| - |
|
17 |
#
|
| - |
|
18 |
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
| - |
|
19 |
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
| - |
|
20 |
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
| - |
|
21 |
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
| - |
|
22 |
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
| - |
|
23 |
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
| - |
|
24 |
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
| - |
|
25 |
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
| - |
|
26 |
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
| - |
|
27 |
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
| - |
|
28 |
|
| - |
|
29 |
# sneakernet.pl
|
| - |
|
30 |
# Script to perform sneakernet replication of ZFS datasets between two servers
|
| - |
|
31 |
# using an external transport drive.
|
| - |
|
32 |
# Uses ZFS send/receive to replicate datasets to/from the transport drive.
|
| - |
|
33 |
# Optionally uses symmetric encryption to encrypt datasets during transport.
|
| - |
|
34 |
# On the target server, can optionally use GELI to encrypt the datasets on disk.
|
| - |
|
35 |
# Requires a configuration file in YAML format next to the script.
|
| - |
|
36 |
# Author: R. W. Rodlico <rodo@dailydata.net>
|
| - |
|
37 |
# Created: December 2025
|
| - |
|
38 |
# Revision History:
|
| - |
|
39 |
# Version: 0.1 2025-12-10 Initial version
|
| - |
|
40 |
|
| 3 |
use strict;
|
41 |
use strict;
|
| 4 |
use warnings;
|
42 |
use warnings;
|
| 5 |
|
43 |
|
| - |
|
44 |
our $VERSION = '0.1';
|
| - |
|
45 |
|
| 6 |
use FindBin;
|
46 |
use FindBin;
|
| 7 |
use lib "$FindBin::Bin/..";
|
47 |
use lib "$FindBin::Bin/..";
|
| 8 |
use Data::Dumper;
|
48 |
use Data::Dumper;
|
| 9 |
use ZFS_Utils qw(loadConfig shredFile logMsg makeReplicateCommands mountDriveByLabel mountGeli runCmd $logFileName $displayLogsOnConsole);
|
49 |
use ZFS_Utils qw(loadConfig shredFile logMsg makeReplicateCommands mountDriveByLabel mountGeli runCmd $logFileName $displayLogsOnConsole);
|
| 10 |
|
50 |
|
| Line 25... |
Line 65... |
| 25 |
# file created on source server to track last copyed dataset
|
65 |
# file created on source server to track last copyed dataset
|
| 26 |
'status_file' => "$0.status",
|
66 |
'status_file' => "$0.status",
|
| 27 |
#information about source server
|
67 |
#information about source server
|
| 28 |
'source_server' => {
|
68 |
'source_server' => {
|
| 29 |
'hostname' => '', # used to see if we are on source
|
69 |
'hostname' => '', # used to see if we are on source
|
| 30 |
'poolname' => '', # name of the ZFS pool to export
|
70 |
'poolname' => 'pool', # name of the ZFS pool to export
|
| - |
|
71 |
# if set, will generate a report via email or by storing on a drive
|
| - |
|
72 |
'report' => {
|
| - |
|
73 |
'email' => 'tech@example.org',
|
| - |
|
74 |
'subject' => 'AG Transport Report',
|
| - |
|
75 |
'targetDrive' => {
|
| - |
|
76 |
'label' => '',
|
| - |
|
77 |
'mount_point' => '',
|
| - |
|
78 |
}
|
| - |
|
79 |
}
|
| 31 |
},
|
80 |
},
|
| 32 |
#information about target server
|
81 |
#information about target server
|
| 33 |
'target_server' => {
|
82 |
'target_server' => {
|
| 34 |
'hostname' => '', # used to see if we are on target
|
83 |
'hostname' => '', # used to see if we are on target
|
| 35 |
'poolname' => '', # name of the ZFS pool to import
|
84 |
'poolname' => 'backup', # name of the ZFS pool to import
|
| 36 |
# if this is set, the dataset uses GELI, so we must decrypt and
|
85 |
# if this is set, the dataset uses GELI, so we must decrypt and
|
| 37 |
# mount it first
|
86 |
# mount it first
|
| 38 |
'geli' => {
|
87 |
'geli' => {
|
| 39 |
'keydiskname' => 'replica', # the GPT label of the key disk
|
88 |
'keydiskname' => 'replica', # the GPT label of the key disk
|
| 40 |
'keyfile' => 'geli.key', # the name of the key file on keydiskname
|
89 |
'keyfile' => 'geli.key', # the name of the key file on keydiskname
|
| 41 |
'localKey' => 'e98c660cccdae1226550484d62caa2b72f60632ae0c607528aba1ac9e7bfbc9c', # hex representation of the local key part
|
90 |
'localKey' => 'e98c660cccdae1226550484d62caa2b72f60632ae0c607528aba1ac9e7bfbc9c', # hex representation of the local key part
|
| 42 |
'target' => '/media/geli.key', # location to create the combined keyfile
|
91 |
'target' => '/media/geli.key', # location to create the combined keyfile
|
| 43 |
'poolname' => '', # name of the ZFS pool to import
|
92 |
'poolname' => 'backup', # name of the ZFS pool to import
|
| 44 |
'diskList' => [
|
93 |
'diskList' => [
|
| 45 |
'/dev/gpt/sneakernet_disk'
|
94 |
'da0',
|
| - |
|
95 |
'da1'
|
| 46 |
], # list of disks to try to mount the dataset from
|
96 |
], # list of disks to try to mount the dataset from
|
| - |
|
97 |
},
|
| - |
|
98 |
'report' => {
|
| - |
|
99 |
'email' => '',
|
| - |
|
100 |
'subject' => '',
|
| - |
|
101 |
'targetDrive' => {
|
| - |
|
102 |
'label' => 'sneakernet_report',
|
| - |
|
103 |
'mount_point' => '/mnt/sneakernet_report',
|
| - |
|
104 |
}
|
| 47 |
}
|
105 |
}
|
| 48 |
},
|
106 |
},
|
| 49 |
|
- |
|
| 50 |
'transport' => {
|
107 |
'transport' => {
|
| 51 |
# this is the GPT label of the sneakernet disk
|
108 |
# this is the GPT label of the sneakernet disk
|
| 52 |
'disk_label' => 'sneakernet',
|
109 |
'disk_label' => 'sneakernet',
|
| 53 |
# where we want to mount it
|
110 |
# where we want to mount it
|
| 54 |
'mount_point' => '/mnt/sneakernet',
|
111 |
'mount_point' => '/mnt/sneakernet',
|
| Line 59... |
Line 116... |
| 59 |
'key' => '', # openssl rand 32 | xxd -p | tr -d '\n' > test.key
|
116 |
'key' => '', # openssl rand 32 | xxd -p | tr -d '\n' > test.key
|
| 60 |
'IV' => '00000000000000000000000000000000',
|
117 |
'IV' => '00000000000000000000000000000000',
|
| 61 |
},
|
118 |
},
|
| 62 |
},
|
119 |
},
|
| 63 |
'datasets' => {
|
120 |
'datasets' => {
|
| 64 |
'iscsi' => {
|
121 |
'dataset1' => {
|
| 65 |
'source' => 'storage/backup/iscsi',
|
122 |
'source' => 'pool/dataset1',
|
| 66 |
'target' => 'storage/backup/iscsi',
|
123 |
'target' => 'backup/dataset1',
|
| 67 |
'filename' => 'iscsi'
|
124 |
'filename' => 'dataset1'
|
| 68 |
},
|
125 |
},
|
| 69 |
'files_share' => {
|
126 |
'files_share' => {
|
| 70 |
'source' => 'storage/backup/files_share',
|
127 |
'source' => 'pool/files_share',
|
| 71 |
'target' => 'storage/backup/files_share',
|
128 |
'target' => 'backup/files_share',
|
| 72 |
'filename' => 'files_share'
|
129 |
'filename' => 'files_share'
|
| 73 |
},
|
130 |
},
|
| 74 |
}
|
131 |
}
|
| 75 |
};
|
132 |
};
|
| 76 |
|
133 |
|
| 77 |
# read the status file and return as list
|
134 |
# read the status file and return as list
|
| 78 |
sub getStatusFile {
|
135 |
sub getStatusFile {
|
| 79 |
my $filename = shift;
|
136 |
my $filename = shift;
|
| 80 |
# read in history/status file
|
137 |
# read in history/status file
|
| 81 |
my @lines;
|
138 |
my @lines = ();
|
| 82 |
if ( -e $filename && open my $fh, '<', $filename ) {
|
139 |
if ( -e $filename && open my $fh, '<', $filename ) {
|
| 83 |
chomp( @lines = <$fh> );
|
140 |
chomp( @lines = <$fh> );
|
| 84 |
close $fh;
|
141 |
close $fh;
|
| 85 |
logMsg("Read status file '$filename' with contents:\n" . join( "\n", @lines ) . "\n");
|
142 |
logMsg("Read status file '$filename' with contents:\n" . join( "\n", @lines ) . "\n");
|
| 86 |
} else {
|
143 |
} else {
|
| 87 |
logMsg("Error: could not read status file '$filename': $!");
|
144 |
logMsg("Error: could not read status file '$filename', assuming a fresh start: $!");
|
| 88 |
die;
|
- |
|
| 89 |
}
|
145 |
}
|
| 90 |
return \@lines;
|
146 |
return \@lines;
|
| 91 |
}
|
147 |
}
|
| 92 |
|
148 |
|
| 93 |
# write the status list to file
|
149 |
# write the status list to file
|
| Line 161... |
Line 217... |
| 161 |
my @files = map{ $dirname . "/$_" } grep { -f "$dirname/$_" } readdir($dh);
|
217 |
my @files = map{ $dirname . "/$_" } grep { -f "$dirname/$_" } readdir($dh);
|
| 162 |
closedir $dh;
|
218 |
closedir $dh;
|
| 163 |
foreach my $file (@files) {
|
219 |
foreach my $file (@files) {
|
| 164 |
unlink $file or warn "Could not unlink $file: #!\n";
|
220 |
unlink $file or warn "Could not unlink $file: #!\n";
|
| 165 |
}
|
221 |
}
|
| 166 |
return;
|
- |
|
| 167 |
}
|
222 |
}
|
| 168 |
|
223 |
|
| 169 |
|
224 |
|
| 170 |
|
225 |
|
| 171 |
# how to handle a fatal error
|
226 |
# how to handle a fatal error
|
| Line 183... |
Line 238... |
| 183 |
|
238 |
|
| 184 |
# If a YAML config file exists next to the script, load and merge it
|
239 |
# If a YAML config file exists next to the script, load and merge it
|
| 185 |
$config = loadConfig($configFileName, $config );
|
240 |
$config = loadConfig($configFileName, $config );
|
| 186 |
|
241 |
|
| 187 |
# set some defaults
|
242 |
# set some defaults
|
| 188 |
$config->{'status_file'} = "$0.status" unless ( defined $config->{'status_file'} );
|
243 |
$config->{'status_file'} //= "$0.status";
|
| 189 |
|
- |
|
| 190 |
|
244 |
|
| 191 |
fatalError( "Invalid config file: missing source and/or target server" )
|
245 |
fatalError( "Invalid config file: missing source and/or target server" )
|
| 192 |
unless (defined $config->{source_server} && defined $config->{target_server});
|
246 |
unless (defined $config->{source_server} && defined $config->{target_server});
|
| 193 |
|
247 |
|
| 194 |
# mount the transport drive, fatal error if we can not find it
|
248 |
# mount the transport drive, fatal error if we can not find it
|