#!/usr/bin/env perl # Copyright (c) 2026, rodolico # 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. # # updateConfigKeys - Sample script for updating configuration keys during sneakernet cleanup # # This script demonstrates how to programmatically update configuration keys in a YAML # configuration file during the cleanup phase of the sneakernet process. It's particularly # useful for updating sensitive values like encryption keys, hostnames, email addresses, # and other configuration parameters that may need to be changed after a sneakernet # transfer is complete. # # The script uses dot notation to reference nested configuration keys (e.g., # 'target.geli.poolname' or 'datasets.dataset1.target') and creates a timestamped # backup before making any changes. # # FEATURES: # - Update existing keys with new values # - Delete keys by using 'DELETE' as the value # - Create a new key whose immediate parent already exists (e.g. adding # 'target.report.subject' when 'target.report:' is already a mapping) - creating a key # several levels deeper than anything currently in the file is not supported (see below) # - Automatic timestamped backup before modifications # - Comments, key order, and formatting elsewhere in the file are preserved - only the # lines this run actually changes are touched # # This is provided as a sample implementation that can be customized for specific # cleanup requirements in sneakernet workflows. # # USAGE: # Edit the @updates array in the script to specify the configuration keys you want # to update or delete: # - To update: 'key.path=newvalue' # - To delete: 'key.path=DELETE' # # Then run the script to apply the changes to the configuration file. # # LIMITATION vs. earlier versions of this script: creating a key whose PARENT does not # exist either (e.g. 'target.geli.poolname' when there is no 'target.geli:' mapping at # all yet) is refused with a clear error rather than silently inventing the missing # intermediate mapping - see YAMLPatch.md's insert_kv/insert_block for why (a path-addressed # editor of the existing file's real lines has no reasonable place to invent structure that # was never there, unlike a plain in-memory hash). Add the intermediate mapping to the # config by hand (or a preceding @updates entry via insert_block, see YAMLPatch.md) first. # # REQUIREMENTS: # - Perl with YAMLPatch.pm on @INC. sneakernet's own 'use lib "$FindBin::Bin/.."' already # makes this true wherever ZFS_Utils.pm is deployed, since YAMLPatch.pm ships alongside it. # use strict; use warnings; use YAMLPatch; use File::Copy qw(copy); use POSIX qw(strftime); # Configuration file path. When this script runs as a sneakernet cleanup script, it is eval'd # inside sneakernet's own process (see executeCleanupScript in sneakernet/sneakernet) with no # 'chdir' beforehand, so a relative path here would resolve against cron's working directory, not # the install directory - silently operating on the wrong file (or failing outright). Reach for # sneakernet's own $programDefinition->{configFileName} instead, via a NESTED eval: $programDefinition # is a lexical declared in sneakernet's file scope, reachable only through the closure that string # eval provides, so referencing it directly here would fail to COMPILE when this script is run # standalone (outside sneakernet) - wrapping the reference in its own eval STRING defers that # compile-time check to runtime and lets it fail safely, caught below, when there is no enclosing # sneakernet process to find the variable in. my $configFileFromScope = eval '$programDefinition->{configFileName}'; my $configFile = (defined $configFileFromScope && $configFileFromScope ne '') ? $configFileFromScope : 'sneakernet.conf.yaml'; # fallback for standalone testing outside sneakernet # Result array to collect output messages my @result; my @errors; my $caller = caller(); # Check if called from another script and, if not, prints updates to STDOUT # if we are being called from sneakernet, get verbosity level, otherwise just set to 5 my $verbosityLevel = ($caller && defined $ZFS_Utils::verboseLoggingLevel) ? $ZFS_Utils::verboseLoggingLevel : 5; # Array of key-value pairs in dot notation # To update a key: 'key.path=newvalue' # To delete a key: 'key.path=DELETE' # The last two examples below illustrate creating a brand-new key - see the LIMITATION note # near the top of this file: 'target.geli.poolname' only works if 'target.geli:' is already # a mapping in the file (even an empty one), and likewise 'datasets.dataset1' for its entry. my @updates = ( 'debug=1', 'dryrun=0', 'source.hostname=production', 'source.report.email=admin@example.com', 'target.geli.poolname=newbackup', 'datasets.dataset1.target=newbackup', # 'datasets.oldDataset=DELETE', # Example: delete a key ); # Create backup of the original file with timestamp my $timestamp = strftime("%Y%m%d_%H%M%S", localtime); my $backupFile = "$configFile.bak.$timestamp"; if (!copy($configFile, $backupFile)) { push @errors, "Failed to create backup: $!\n"; goto RETURN; } push @result, "Created backup: $backupFile\n"; # Load the YAML configuration file into a variable named deliberately UNLIKE sneakernet's own # $config - this script runs inside sneakernet's process via string eval, and naming this the same # would shadow sneakernet's ambient $config for the rest of this eval, hiding it from the rest of # this script's own code (e.g. if it wanted to read something like the ambient # transport.mountPoint for a log message). The two are never the same object regardless of name - # this one is always freshly loaded from disk - but a distinct name keeps that unambiguous to a # future reader/editor of this script. my $diskConfig = eval { YAMLPatch->load_file($configFile) }; unless ( $diskConfig ) { push @errors, "Failed to load '$configFile': $@\nAborting without making any changes.\n"; goto RETURN; } if ( $verbosityLevel ) { push @result, "Original configuration:\n"; # Dumper($config) intentionally commented out - this dumps the ENTIRE config, including # transport.encryptionKey and target.geli.localKey in plaintext, into whatever @result feeds # (the sneakernet report, which is emailed). This was a development aid, not something safe to # leave enabled. See sneakernet/allowKeyRotation.md Part 1. # push @result, Dumper($config); push @result, "\n" . "=" x 60 . "\n\n"; } # Process each update. Each one is wrapped in its own eval so a single bad entry (a value # YAMLPatch's safety checks refuse, or a path whose parent doesn't exist - see the LIMITATION # note above) is reported and skipped, the same "keep going" resilience the original # malformed-format check already had, just extended to cover YAMLPatch's own refusals too. foreach my $update (@updates) { # Split the update into key path and value my ($keyPath, $value) = split(/=/, $update, 2); unless (defined $value) { push @errors, "Warning: Invalid format for update '$update'. Skipping.\n"; next; } my @keys = split(/\./, $keyPath); my $finalKey = $keys[-1]; my $parentPath = join('.', @keys[0 .. $#keys - 1]); if ($value eq 'DELETE') { unless ($diskConfig->exists_path($keyPath)) { push @errors, "Delete skipped (key not found): $keyPath\n\n"; next; } my $oldValue = $diskConfig->get($keyPath)->{value}; eval { $diskConfig->delete($keyPath); 1 } or do { push @errors, "Delete failed for $keyPath: $@\n"; next; }; push @result, "Deleted: $keyPath\n"; push @result, " Old value: $oldValue\n\n"; } elsif ($diskConfig->exists_path($keyPath)) { my $oldValue = $diskConfig->get($keyPath)->{value}; eval { $diskConfig->set($keyPath, $value, style => 'keep'); 1 } or do { push @errors, "Update failed for $keyPath: $@\n"; next; }; push @result, "Updated: $keyPath\n"; push @result, " Old value: $oldValue\n"; push @result, " New value: $value\n\n"; } else { # New key - the immediate parent must already exist as a mapping (see the LIMITATION # note near the top of this file); insert_kv itself refuses otherwise with a clear # message naming the missing parent, which the eval below surfaces into @errors. eval { $diskConfig->insert_kv(parent => $parentPath, key => $finalKey, value => $value); 1 } or do { push @errors, "Create failed for $keyPath: $@\n"; next; }; push @result, "Created: $keyPath\n"; push @result, " New value: $value\n\n"; } } if ( $verbosityLevel > 2 ) { push @result, "=" x 60 . "\n\n"; push @result, "Updated configuration:\n"; # Dumper($config) intentionally commented out - see the matching note above. # push @result, Dumper($config); } # Save the updated configuration. YAMLPatch::save() writes to a temp file in the same # directory and renames it into place atomically (same recovery shape the old manual # temp-file dance here used to implement by hand) - but its own gate 3 ("the semantic delta # must equal the declared intent exactly", YAMLPatch.md) is a STRONGER verification than the # old reload-and-spot-check loop this replaced: it fails closed on ANY unintended change # anywhere in the file, not just the specific keys this run touched. The full timestamped # backup above remains the recovery path regardless. eval { $diskConfig->save($configFile); 1 } or do { push @errors, "Save FAILED - the live config was NOT touched (verified by YAMLPatch's own" . " commit gates). The full backup at $backupFile is untouched. Error: $@\n"; goto RETURN; }; push @result, "\nChanges saved to $configFile (verified before replacing the live file)\n"; push @result, "Backup saved as $backupFile\n"; RETURN: chomp @result; chomp @errors; # When run standalone, print to STDOUT if ($caller) { return ( join( "\n", @result ) . "\n", @errors ? join("\n", @errors) : "" ); } else { print "Results Summary:\n"; print join( "\n", @result ) . "\n"; if (@errors) { print "\n=== CLEANUP SCRIPT ERRORS ===\n"; print join("\n", @errors) . "\n"; } exit(@errors > 0 ? 1 : 0); } # End of script