Project: Automated ZFS replication via encrypted removable media
Environment: FreeBSD with ZFS and GELI encryption
Repository: http://svn.dailydata.net/svn/zfs_utils/trunk
License: BSD 2-Clause (FreeBSD License)
The sneakernet project provides a complete automated solution for air gap server replication using ZFS datasets and encrypted transport media. This implementation was developed for a production environment requiring monthly off-site backups with split-key security architecture.
Key Features:
A production deployment required the following specifications:
| Requirement | Implementation |
|---|---|
| Replication Schedule | Monthly updates from in-house backup server to air gap server (target) |
| Transport Media | 3× 1.9TB SSD drives in rotation |
| Drive Rotation | One at source, one at target, one in transit — minimizes site visits |
| Security Model | Multi-layer encryption with split-key architecture |
| Location | Air gap server in unsecured location (mandatory encryption) |
| Automation | Fully automated with maintenance script execution |
Encryption Layers:
GELI full disk encryption on air gap serverGELI key derived from:Split-key advantage: Neither component alone can decrypt the air gap server. Compromise of a single key (server or transport) does not expose data.
GELI kernel module (for target server encryption)
Repository URL: http://svn.dailydata.net/svn/zfs_utils/trunk
Sub-project: sneakernet
Export the project:
mkdir -p /usr/local/opt svn export http://svn.dailydata.net/svn/zfs_utils/trunk /usr/local/opt/zfs_utils cd /usr/local/opt/zfs_utils
The sneakernet script uses YAML configuration files:
sneakernet.conf.yamlsneakernet.datastructureKey Configuration Sections:
source — Source server settings (hostname, poolname)target — Target server settings (hostname, poolname, GELI config)transport — Transport drive settings (label, encryption key, mountpoint)datasets — Dataset replication mappingssource.cleanupScriptSchedule — Month-based scheduling for maintenance scriptssource.oneShotCleanup — One-time maintenance scripts directorytarget.maintenanceMode — Flag files that pause replicationtarget.report.targetDrive — Optional report drive settingsExample minimal configuration snippet:
source: hostname: backup-primary poolname: tank cleanUpScriptsDir: /usr/local/opt/zfs_utils/sneakernet/cleanupScripts cleanupScriptSchedule: cleanSnaps: [1,2,3,4,5,6,7,8,9,10,11,12] scrubZFS: [2,5,8,11] oneShotCleanup: /usr/local/opt/zfs_utils/sneakernet/oneShotCleanup target: hostname: airgap-backup poolname: backup maintenanceMode: flags: local: /tmp/maintenance transport: flags/maint.flag report: targetDrive: label: report fstype: msdos mountPoint: /mnt/report transport: label: sneakernet fstype: ufs mountPoint: /mnt/sneakernet encryptionKey: your_hex_key_here datasets: dataset1: source: tank target: backup dataset: data
The source server performs the following operations automatically:
Command:
/usr/local/opt/zfs_utils/sneakernet/sneakernet
The target server performs the following operations automatically:
GELI encrypted disks using combined keyshutdownAfterReplication enabled)Command:
/usr/local/opt/zfs_utils/sneakernet/sneakernet
The script automatically detects whether it's running on the source or target server by comparing the hostname to the configuration. No mode flags required.
The three-drive rotation minimizes operational overhead:
Normal Operation Cycle:
Benefits:
Drive Labeling:
Each transport drive should be labeled with GPT labels:
# Label drives for easy identification gpart add -t freebsd-ufs -l sneakernet /dev/ada0 gpart add -t freebsd-ufs -l sneakernet /dev/ada1 gpart add -t freebsd-ufs -l sneakernet /dev/ada2 # Create filesystems newfs -U /dev/gpt/sneakernet newfs -U /dev/gpt/sneakernet newfs -U /dev/gpt/sneakernet
Generate transport key:
# Generate 32-byte (256-bit) key in hex format openssl rand 32 | xxd -p | tr -d '\n' > /secure/path/transport.key chmod 400 /secure/path/transport.key
final_key = server_key ⊕ operator_keyGenerate split keys:
# Generate the final GELI key (this will be stored securely off-site) openssl rand 512 > /secure/offsite/final_geli.key # Generate operator key openssl rand 512 > /media/operator/operator.key # Generate server key (XOR of final and operator keys) # This requires the makeGeliKey utility from ZFS_Utils /usr/local/opt/zfs_utils/utilities/makeGeliKey \ /secure/offsite/final_geli.key \ /media/operator/operator.key \ /secure/server/server.key
If transport drive compromised:
# Generate new symmetric key openssl rand 32 | xxd -p | tr -d '\n' > /secure/path/new_transport.key # Deploy as maintenance script on next run # Old data on compromised drive remains encrypted with old key
If operator key compromised:
# Generate new operator key openssl rand 512 > /media/operator/new_operator.key # Retrieve final GELI key from secure off-site storage # Regenerate server key using makeGeliKey /usr/local/opt/zfs_utils/utilities/makeGeliKey \ /secure/offsite/final_geli.key \ /media/operator/new_operator.key \ /secure/server/server.key # Operator must use new key on next visit
Key rotation can be automated through maintenance scripts. New keys deployed during normal replication cycles without requiring emergency site visits.
Usage: sneakernet [OPTIONS] Options: -n, --dryrun Run in dry-run mode (no writes, shows what would happen) -v, --verbosity LEVEL Set verbosity level (0-5) -d, --debug LEVEL Debug breakpoint level (integer) -V, --version Display version number -h, --help Display this help message Verbosity Levels: 0 = Errors and critical messages only 1 = Standard operations 2 = Detailed operations 3 = Debugging information 4 = Snapshot lists 5 = Full detailed output
Examples:
# Test configuration without making changes sneakernet --dryrun # Run with detailed logging sneakernet -v 2 # Maximum verbosity for troubleshooting sneakernet -v 5
Maintenance scripts are Perl scripts that execute on the target server after replication completes.
Script Requirements:
($resultsString, $errorsString)$resultsString is optional informational output (newline-delimited)$errorsString is empty on success, or newline-delimited errorscleanUpScriptsDir on transportcleanupScripts/helloWorld for the authoritative template
Example maintenance script (based on cleanupScripts/helloWorld):
#!/usr/bin/env perl # helloWorld-style template use strict; use warnings; my @result; my @errorMessages = (); my $caller = caller(); # Check if called from another script and, if not, prints updates to STDOUT my $verbosityLevel = ($caller && defined $ZFS_Utils::verboseLoggingLevel) ? $ZFS_Utils::verboseLoggingLevel : 5; # Add messages to result array push @result, "hello"; push @result, "world" if $verbosityLevel > 2; # Add test error message (remove for production) push @errorMessages, "This is a test error message"; # Return two strings: results and errors return ( join("\n", @result) . "\n", @errorMessages ? join("\n", @errorMessages) : "" );
Month-based scheduling (monthly updates assumed):
Configure the scripts that should be transferred on the current month:
source: cleanupScriptSchedule: zpoolStats: [1,2,3,4,5,6,7,8,9,10,11,12] cleanSnaps: [1,2,3,4,5,6,7,8,9,10,11,12] scrubZFS: [2,5,8,11] trimZFS: [3,6,9,12] runSmart: [1,7]
Scripts not listed are not copied. This aligns with monthly updates to the air gap target.
Deploy maintenance script:
# On source server: encrypt and copy to cleanup directory openssl enc -aes-256-cbc -salt -in cleanup_script.pl \ -out /configured/cleanup/dir/cleanup_script.pl.enc \ -pass file:/secure/transport.key
Before replication, the source pre-calculates stream sizes and compares them to historical averages
stored in the source history file. If a dataset exceeds its maxDelta threshold, replication is
aborted to prevent unexpected large transfers (e.g., ransomware). The transfer is also aborted if
the estimated total size exceeds transport capacity.
Reports send a brief summary, followed by the logs generated. The degree of detail in the logs are controlled by the verbosity level, so can vary based on this setting. Most mainteannce scripts will change the amount of output based on the verbosity level also.
Note: While it is assumed the output of the reports is e-mail for the source machine and a file written to disk on the target, both options are available in both modes, and are not exclusive. Source can send e-mail and write to a disk file, for example.
Source server sends email reports via configured SMTP:
Report Contents:
Configuration:
source: report: subject: "Sneakernet Replication Report" emailTo: admin@example.com emailFrom: backup@example.com
Target server writes reports to removable media:
Report Drive Configuration:
target: report: targetDrive: label: report_drive fstype: ufs mountPoint: /mnt/report
Report Contents:
This implementation balances security with operational efficiency:
Security Advantages:
Operational Advantages:
| Problem | Solution |
|---|---|
| Transport drive not detected | Verify GPT label matches config, check dmesg for device |
| Decryption fails on target | Verify transport key matches on both servers |
| Serial.txt already exists | Previous run not completed on target, investigate |
| Serial.txt missing on target | Drive already processed or not created on source |
| GELI disks won't unlock | Verify operator key present, check XOR key generation |
| Server won't shutdown | Check shutdownAfterReplication config, review logs |
# Run with maximum verbosity and dry-run sneakernet --dryrun -vvvvv 2>&1 | tee debug.log # Check ZFS_Utils log file tail -f /tmp/zfs_utils.log # location overwritten by sneakernet.conf.yaml # Check sneakernet log file (if configured) tail -f /path/to/sneakernet.log
logFile in YAML (default: sneakernet.log)/tmp/zfs_utils.log unless defined in sneakernet.conf.yamlstatusFile (tracks last replicated snapshots)
See CHANGELOG.md in the repository for complete revision history.
Repository: http://svn.dailydata.net/svn/zfs_utils/trunk
Author: R. W. Rodolico
License: BSD 2-Clause (FreeBSD License)
Company: Daily Data Inc.
For bug reports, feature requests, or questions, contact the repository maintainer via web form at https://dailydata.net/contact-us/.
This document was edited for clarity and formatting by an AI agent (GitHub Copilot), and the content was reviewed for accuracy afterward.