Rev 34 | Blame | Compare with Previous | Last modification | View Log | Download | RSS feed
# sneakernet
Lightweight perl script to perform zfs replication over a transport media like a disk drive. Data may be encrypted in transit using symentric key. Function (send/receive) determined by server name (hostname) which is matched in config file entries under 'source' and 'target'.
## Usage
CLI examples
```
# run
sneakernet
```
## $config structure
sneakernet uses a top-level $config object stored as YAML (e.g., sneakernet.config.yaml) to control runtime behavior. If this file does not exist, a sample is created on first run. **You must edit this config with correct values**. Failure to do so could result in lost data.
Example YAML:
```yaml
---
datasets:
dataset1:
filename: dataset1
source: pool/dataset1
target: backup/dataset1
files_share:
filename: files_share
source: pool/files_share
target: backup/files_share
source_server:
hostname: nas
poolname: pool
status_file: ./sneakernet.status
report:
email: tech@example.org
subject: 'AG Transport Report'
targetDrive:
fstype: ''
label: ''
mount_point: ''
target_server:
hostname: airgap
poolname: backup
geli:
diskList:
- da0
- da1
keydiskname: replica
keyfile: geli.key
localKey: e98c660cccdae1226550484d62caa2b72f60632ae0c607528aba1ac9e7bfbc9c
target: /media/geli.key
poolname: backup
report:
email: ''
subject: ''
targetDrive:
fstype: msdos
label: sneakernet_report
mount_point: /mnt/sneakernet_report
transport:
disk_label: sneakernet
mount_point: /mnt/sneakernet
timeout: 600
encryption:
IV: '00000000000000000000000000000000'
key: ''
```
Field reference
- datasets (hash, required): List of one or more definitions which are transferred
- filename (string, optional): filename to be used when storing on disk. If not entered, the dataset name is used
- source (string, required): full zfs path (no leading slash) on the source machine
- target (string, required): full zfs path (no leading slash) on target machine
- source_server (hash, required): information used when processing on source server
- hostname (string, required): output of hostname command on source server
- poolname (string, required): name of the root pool on source server (not used)
- status_file (string, required): full path to a status file to be created listing last snapshot have already been processed for each dataset/child dataset
- report (hash, optional): contains parameters for sending report
- email (hash, optional): information on how to send report via e-mail
- address (string, optional): single e-mail address to send report to
- subject (string, optional); Subject line of e-mail
- targetDrive (hash, optional): information on saving the file on a local disk
- fstype (string, optional): use either **msdos** or **ufs** depending on how your disk is formatted. Default is ufs, which is not easily read on Windows or MacOS machines.
- label (string, optional): msdos label of partition to be mounted for the report
- mount_point (string, optional): directory to mount the partition on. Calculated as /mnt/label if not set
- target_server (hash, required): information used when processing target server
- hostname (string, required): output of hostname command on target server
- poolname (string, required): name of the root pool on target server (not used)
- geli (hash, optional): information to decrypt geli encrypted drives and mount zpool
- disk_list (array, optional): list of geli encrypted disks which make up the pool. Searched for if not listed (drive names change on BSD)
- keydiskname (string, required): label on ufs file system containing key file
- keyfile (string, optional): name of the file on disk
- localKey (hex string, optional): 64 hexadecimal characters (256 bit) which is xor'd with keyfile to calculate target
- target (string, required): name of file on disk which is the key to unlock the geli disks. If keyfile and local key exist, this is created by xoring them, but may be found some other way.
- report (hash, optional): contains parameters for sending report
- email (hash, optional): information on how to send report via e-mail
- address (string, optional): single e-mail address to send report to
- subject (string, optional); Subject line of e-mail
- targetDrive (hash, optional): information on saving the file on a local disk
- fstype (string, optional): use either **msdos** or **ufs** depending on how your disk is formatted. Default is ufs, which is not easily read on Windows or MacOS machines.
- label (string, optional): msdos label of partition to be mounted for the report
- mount_point (string, optional): directory to mount the partition on. Calculated as /mnt/label if not set
- transport (hash, required): hash with information on transport media.
- disk_label (string, required): label on transport disk. Assumed to be ufs disk with label
- mount_point (string, optional): mount point of disk with disk_label label. Calculated as /mnt/disk_label if not defined
- timeout (integer, optional): number of seconds to wait for transport disk to appear. Defaults to 600s (10 min).
- encryption (hash, optional): information used to encrypt/decrypt data on transport disk. This is a symetric key, so must be the same on both source and target machines
- IV (hex string, optional): Initial vector for encryption. Defaults to all zeros if not set
- key (hex string, optional): 64 hexadecimal characters (256bit) used to encrypt files in transit (while on transport disk). If not set, files are unencrypted
-
Notes on YAML
- Use a UTF-8 encoded file (config.yaml) and preserve numeric types (no quotes) for numeric fields.
- Tooling reads YAML and validates types; comments are allowed but not preserved in runtime exports.
## Overview
The script acts differently based on whether a call to *hostname* returns the source or target servers names.
The script requires the library (perl module) ZFS_Utils.pm to be in the parent document of the script. Most functions are defined there. See ZFS_Utils.md for documentation.
**Note:** the variable \$DEBUG, if set to non-zero, will perform all functions except actual writes. In source mode, it will not create the files on the transport disk, in target mode it will not actually perform the receive to the datasets. However, the "what I would do" shows up in the logs.
#### Common startup
- Load config fileinto hash \$config.
- If config file does not exist, it will create it and exit with a message
- If config file does not contain source or target server names, it assumes it is not edited and exits with a message
- Mount transport disk
- wait *timeout* seconds for disk label to show up in /dev/gpt, displaying prompt every 14 seconds. Dies if it can not find it
- mount transport disk on *mount_point*
- Get results of *hostname -s* to determine if we are in source mode or target. If the name matches neither, fails with an error message
#### Source Mode
- read status file
- Perform replication, placing files on transport disk.
- The files are named with the dataset, with all slashes replaced with periods, so datasets with embedded period will fail when in Target Mode
- if encryption is defined, pass send stream through openssl (*openssl enc*)
- Writes the status file to disk. Status file will contain one line for each dataset sent, containing the last snapshot sent
#### Target Mode
- If geli encryption defined
- Find geli key
- Find geli disks, either defined or by searching for all disks which are not
- mounted
- partitioned
- a member of an existing zpool
- decrypt all geli disks
- Import the zpool
- for each file in the root ofthe transport disk
- receive into correct dataset
- if filename has periods, calculate dataset as a path in the zpool
- If encryption is defined, pass the file through openssl in decrypt mode (*openssl enc -d*)
#### Common Cleanup
- Umount transport disk and remove its *mount_path*
- Send report if defined
## Other Notes
Some areas of the script require further explanation. This section covers that.
#### Transport Encryption
Transport encryption is optionally set by placing a hex string representation of a 256bit key in transport/encryption. This key can be generated by hand, or you can use openssl, xxd and tr with the command:
`openssl rand 32 | xxd -p | tr -d '\n'`
If this key exists, the contents of the data on the transport script are encrypted with it, and can not be viewed without using the same key to decrypt (symetric encryption, or one time key pad)
You can further boost security by setting the IV parameter to something other than 0's.
#### Status file
Since the source server can not contact the target to see what is on there, the status file on the source server keeps track of what the last snapshot sent was. If it does not exist, the system assumes we need a full copy, which can require a very large media to do the transfer (size of files plus size of all deltas).
If your target server already has some snapshots on it, you can find the newest snapshot on it for each dataset, then make the file yourself.
A sample from my test machine shows two datasets on the zpool *storage* name ComputerFiles and android. android has a child dataset which was out of sync, so that is show also.
Running the command `zfs list -rt snap -H -o name storage` shows the following.
```
storage@daily_2025-12-07_16.09.00--7d
storage/ComputerFiles@daily_2025-12-07_16.09.00--7d
storage/ComputerFiles@daily_2025-12-10_02.10.00--7d
storage/android@daily_2025-12-07_16.09.00--7d
storage/android@daily_2025-12-10_16.54.00--7d
storage/android/old@daily_2025-12-07_16.09.00--7d
storage/android/old@daily_2025-12-10_16.54.00--7d
storage/android/old@daily_2025-12-10_16.58.00--7d
```
From that, I can grab only the latest snapshots from each and create a status file containing
```
storage/ComputerFiles@daily_2025-12-07_16.09.00--7d
storage/android@daily_2025-12-07_16.09.00--7d
storage/android/old@daily_2025-12-07_16.09.00--7d
```
#### geli Encryption
This system was designed for an air gap server holding some sensitive information, in an insecure location. As such, we encrypt the disks that make up the zpool using geli, BSD's full disk encryption system. If your system does not use geli encryption, just leave the geli section blank or don't even include it.
###### Finding Disks
Since BSD has a habit of changing disk drive names when a drive is added or removed from the system (on reboot), it is likely not best to rely on the drive names. However, in some cases it is fine. Listing the drives as an array will limit the drives which are decrypted, resulting in a slightly faster decryptions.
However, if the drives are not listed, a search will be made of all drives which are not mounted, have no slices (partitions), and are not a member of any other zpool. It then attempts to decrypt those. If it finds a drive or two which are not geli disks, is simply moves on to the next one.
###### Encryption Key
To make it easier to run without user interaction, we use a key file for the encryption. This file is passed to geli to decrypt the disks and make them available.
To make it a little safer, we randomly generated two keys of 256bit (32 bytes), then ran the binary XOR function on them to create a third key. When you do this, having two keys allow you to calculate the third one by simply XOR'ing them together.
The script in utilities named *makeGeliKey* can do all this for you. It will generate three keys, arbitrarily naming them combined_geli.key, local_geli_1.key and local_geli_2.key.
The idea is that you use combined_geli.key to encrypt your disks, then you put local_geli_1.key on a thumb drive and the hex representation of local_geli_2.key into the configuration file.
When you have indicated you have a geli setup, you plug the thumb drive in, the system finds it, XOR's it with the hex representation in the config file, generating the combined_geli.key file. After all disks have been decrypted, the system them shreds (erases as per DOD specs) the combined_geli.key. That means even if you lose your thumb drive and it is found by someone else, they can not do anything with it without access to the server, and you can change the local geli keys and generate new ones.
If you do not need this additional layer of security, you can simply the combined_geli.key file on your thumb drive and leave the keyfile and localKey empty. In that case, the decryption will assume the file is on the thumb drive and use it.
#### Disk Labels
The transport disk, the optional security key USB drive, and the optional report USB are all identified by labels. When the system decides it needs a disk, it will look for the label in /dev/gpt or /dev/msdos (depending on what it thinks it needs), then mount by label.
I chose to use UFS for both the keyfile and the transport disk. Security for the former (most people will assume it is a bad disk and reformat) and file system robustness for the latter. May give me time to create another key pair to invalidate the one lost.
For the report USB, I used FAT32 so the disk could be plugged into a Windows or MacOS machine and read easily.
To create the disk structure for the transport and key file media, identify it, then run the following, changing *da9* to the disk name for your device. There are 5 locations for it (don't forget da9p1, where the da9 needs to be changed)
```bash
# create the GPT schema
gpart create -s GPT da9
# show what is there (nothing yet)
gpart list /dev/da9
# add a new slice of type UFS, and give it a label, sneakernet
# use all of the available disk space for this (ie, only one slice)
gpart add -t freebsd-ufs -l sneakernet da9
# show what is up
gpart list /dev/da9
# format the partition with UFS
newfs /dev/da9p1
# it should now show up as an alias in /dev/gpt
ls /dev/gpt
# make a place to mount it
mkdir /mnt/sneakernet
# mount it
mount /dev/gpt/sneakernet /mnt/sneakernet
# unmount it
umount /mnt/sneakernet
# clean up
rmdir /mnt/sneakernet
```
For the report disk, formatted with FAT32, use the following. Again, change all *da9* to the actual device you are using. There are five locations.
```bash
# remove any existing schema
gpart destroy -F /dev/da9
# create an mbr schema
gpart create -s mbr /dev/da9
# add a fat32 file system labeled REPORT using all the space
gpart add -t fat32 -l REPORT /dev/da9
# format it, using msdos and the label.
newfs_msdos -L REPORT -F 32 /dev/da9s1
# show what you have
gpart show /dev/da9
# make a place to mount it
mkdir /mnt/REPORT
# mount it
mount -t msdosfs /dev/msdosfs/REPORT /mnt/REPORT
# ummount it
umount /mnt/REPORT
# clean up
rmdir /mnt/REPORT/
```
## Functions
- getStatusFile
- Reads the status file
- Parameters: file name
- Returns: contents of status file as an array, one entry per line
- writeStatusFile
- writes updated status file. Old status file saved with .bak suffix (only one backup)
- Paramters: file name, array ref
- Returns: nothing
- replaceSlashWithDot
- Replaces all slashes (used in pathnames) with periods so the path can be used as a file name. Removes first element
- Parameters: string
- Returns: string with all slashes removed and first entry removed
- doSourceReplication
- This is the meat of the script when in source mode; it finds all snapshots, calculates the new ones, creates and executes the commands to write them
- Parameters: config, status file contents
- Returns: hashref of new "last snapshot used" for new Status File
- cleanDirectory
- Removes all files from a given directory. Will not touch subdirectories. Used to clean Transport Disk before source server begins transfer
- Parameters: directory name
- Returns: nothing
- fatalError
- Adds a message to the log, then performs a die. Used for critical failure
- Parameters: string to add to log
- Returns: nothing, kills program
Contributing
- Follow semantic versioning for manifest schema changes.
- Tests should simulate inconsistent snapshots in child datasets
License
- Choose an appropriate license (MIT, Apache-2.0, etc.) for your project.
Generated by GNU Enscript 1.6.5.90.