diff options
Diffstat (limited to 'bin')
-rwxr-xr-x | bin/byteback-backup | 264 | ||||
-rwxr-xr-x | bin/byteback-prune | 140 | ||||
-rwxr-xr-x | bin/byteback-receive | 62 | ||||
-rwxr-xr-x | bin/byteback-restore | 125 | ||||
-rwxr-xr-x | bin/byteback-setup-client | 70 | ||||
-rwxr-xr-x | bin/byteback-setup-client-receive | 54 | ||||
-rwxr-xr-x | bin/byteback-snapshot | 40 |
7 files changed, 755 insertions, 0 deletions
diff --git a/bin/byteback-backup b/bin/byteback-backup new file mode 100755 index 0000000..ac9165a --- /dev/null +++ b/bin/byteback-backup @@ -0,0 +1,264 @@ +#!/usr/bin/ruby +# +# Back up this system to a byteback-enabled server (just some command line +# tools and SSH setup). We aim to make sure this backups are easy, complete +# and safe for most types of hosting customer. +# +# See 'man byteback' for more information. + +require 'resolv' + +$LOAD_PATH.unshift('/usr/lib/byteback') + +require 'trollop' +require 'byteback/util' +require 'byteback/log' +include Byteback::Util +include Byteback::Log + +ME = $PROGRAM_NAME.split('/').last + +opts = Trollop.options do + banner "#{ME}: Back up this system to a byteback-enabled server\n " + + opt :destination, 'Backup destination (i.e. user@host:/path)', + type: :string + + opt :source, 'Source paths', + type: :strings, + default: ['/'] + + opt :exclude, 'Paths to exclude', + type: :strings, + short: 'x' + + opt :verbose, 'Show debugging messages' + + opt :retry_number, 'Number of retries on error', + type: :integer, + default: 3 + + opt :retry_delay, 'Number of seconds between retries after an error', + type: :integer, + default: 300 + + opt :ssh_key, 'SSH key filename', + type: :string, + default: '/etc/byteback/key', + short: 'k' + + opt :help, 'Show this message', + short: 'h' + + banner "\nAdditional excludes can be specified using /etc/byteback/rsync_filter, which is an rsync filter file. See the rsync man page for information on how this works.\n" +end + +lock_out_other_processes('byteback-backup') + +@ssh_key = opts[:ssh_key] +@verbose = opts[:verbose] ? '--verbose' : nil +@sources = opts[:source] if opts[:source] +@excludes = opts[:exclude] if opts[:exclude] +@destination = opts[:destination] +@retry_number = opts[:retry_number] +@retry_delay = opts[:retry_delay] + +# Read the default destination +if File.exist?('/etc/byteback/destination') + @destination = File.read('/etc/byteback/destination').chomp +end + +# Set the default SSH key +if File.exist?('/etc/byteback/key') + @ssh_key = '/etc/byteback/key' +end + +# +# Check our destination +# +if @destination =~ /^(?:(.+)@)?([^@:]+):(.+)?$/ + @destination_user, @destination_host, @destination_path = [Regexp.last_match(1), Regexp.last_match(2), Regexp.last_match(3)] +else + fatal('Destination must be a remote path, e.g. ssh@host.com:/store/backups') +end + +# +# Validate & normalise source directories +# +@sources = ['/'] if @sources.nil? + +fatal('No sources specified') if @sources.empty? + +@sources = @sources.map do |s| + s = s.gsub(/\/+/, '/') + fatal("Can't read directory #{s}") unless File.readable?(s) + s +end + +# Automatically exclude anything mounted on a non-local filesystem, plus +# various cache and temporary directories common on Bytemark & Debian +# systems +# +if @excludes.nil? + + PROBABLY_LOCAL = %w( + btrfs + ext2 + ext3 + ext4 + reiserfs + xfs + nilfs + jfs + reiser4 + zfs + rootfs + ) + + COMMON_JUNK = %w( + /swap.file + /tmp + /var/backups/localhost + /var/cache/apt/archives + /var/lib/php5 + /var/tmp + ) + + MOUNT_HEADINGS = %w( spec file vfstype mntops freq passno ) + .map(&:to_sym) + + mounts = File.read('/proc/mounts').split("\n").map do |line| + Hash[MOUNT_HEADINGS.zip(line.split(' '))] + end + + @excludes = + + mounts + .select { |m| !PROBABLY_LOCAL.include?(m[:vfstype]) } + .map { |m| m[:file] } + + + COMMON_JUNK.select { |f| File.exist?(f) } + +end + +@excludes = @excludes.map do |e| + e.gsub(/\/+/, '/') +end + +fatal('Must suply --destination or put it into /etc/bytebackup/destination') unless @destination + +# +# Test ssh connection is good before we start +# +fatal("Could not read ssh key #{@ssh_key}") unless File.readable?(@ssh_key) + +def ssh(*ssh_args) + args = ['ssh', + '-o', 'BatchMode=yes', + '-o', 'ConnectionAttempts=5', + '-o', 'ConnectTimeout=30', + '-o', 'ServerAliveInterval=60', + '-o', 'TCPKeepAlive=yes', + '-x', '-a', + '-i', @ssh_key, + '-l', @destination_user, + @destination_host + ] + + ssh_args + .map { |a| a ? a : '' } + + log_system(*args) +end + +fatal("Could not connect to #{@destination}") unless + ssh('byteback-receive', '--ping', @verbose) == 0 + +# +# Call rsync to copy certain sources, returns exit status (see man rsync) +# +def rsync(*sources) + # Default options include --inplace because we only care about consistency + # at the end of the job, and rsync will do more work for big files without + # it. + # + # The timeout is set to 12 hours - rsync can spend a long time at the + # far end checking over its files at the far end without transfer, so we + # want to wait as long as possible without jeopardising the timing of the + # next backup run. Obviously if rsync itself takes nearly 24 hours for a + # given filesystem, daily backups (with this tool) are out of the question. + # + args = %w( rsync --archive --numeric-ids --delete-delay --inplace --relative --timeout 43200 ) + + args += ['--rsh', "ssh -o BatchMode=yes -x -a -i #{@ssh_key} -l #{@destination_user}"] + args << '--verbose' if @verbose + args += @excludes.map { |x| ['--exclude', x] }.flatten + + # + # Add in the rsync excludes and sources files, if present. + # + if File.exist?('/etc/byteback/excludes') + args += ['--exclude-from', '/etc/byteback/excludes'] + end + + # + # Add in an rsync_filter if required. + # + if File.exist?('/etc/byteback/rsync_filter') + args += ['--filter', 'merge /etc/byteback/rsync_filter'] + end + + # + # To add extra rsync flags, a file can be used. This can have flags all on one line, or one per line. + # + if File.exist?('/etc/byteback/rsync_flags') + args += File.readlines('/etc/byteback/rsync_flags').map(&:chomp) + end + + args += ['--rsync-path', 'rsync --fake-super'] + + args += sources + args << @destination + + log_system(*args) +end + +# +# We treat exit statuses 0 and 24 as success; 0 is "Success"; 24 is "Partial +# transfer due to vanished source files", which we treat as success otherwise +# on some hosts the backup process never finishes. +# +RSYNC_EXIT_STATUSES_TO_ACCEPT = [0, 24] +RSYNC_EXIT_STATUSES_TO_RETRY_ON = [10, 11, 20, 21, 22, 23, 30] + +# Run the file copy, retrying if necessary +# +loop do + status = rsync(*@sources) + + if RSYNC_EXIT_STATUSES_TO_ACCEPT.any? { |s| s === status } + break + elsif RSYNC_EXIT_STATUSES_TO_RETRY_ON.any? { |s| s === status } + + warn "rsync exited with status #{status}" + + if @retry_number > 0 + warn "rsync will retry #{@retry_number} more times, sleeping #{@retry_delay}s" + @retry_number -= 1 + sleep @retry_delay + redo + else + fatal('Maximum number of rsync retries reached') + end + else + fatal("Fatal rsync error occurred (#{status})") + end +end + +info('Backup completed, requesting snapshot') + +# Mark the backup as done on the other end +# +fatal('Backup could not be marked complete') unless + ssh('byteback-receive', '--complete', @verbose) == 0 + +info('Finished') diff --git a/bin/byteback-prune b/bin/byteback-prune new file mode 100755 index 0000000..d005289 --- /dev/null +++ b/bin/byteback-prune @@ -0,0 +1,140 @@ +#!/usr/bin/ruby +# +# Program to free up space on the backup-storage volume, by removing +# backups (whether by age, or importance). +# + +$LOAD_PATH.unshift('/usr/lib/byteback') + +require 'trollop' +require 'byteback' +require 'sys/filesystem' +include Byteback +include Byteback::Log +include Byteback::Util + +opts = Trollop.options do + banner "Prune old backup directories to ensure there's enough space" + + opt :minpercent, 'Start prune when disk has less than this %age free', + type: :integer, + default: 5 + + opt :maxpercent, 'Stop prune when disk has more than this %age free', + type: :integer, + default: 10 + + opt :list, 'List backups in pruning order, no other action' + + opt :prune, 'Prune the next backup if necessary' + + opt :prune_force, 'Prune the next backup regardless' + + opt :order, "Order backups by 'age' or 'importance'", + type: :string, + default: 'importance' + + opt :verbose, 'Show debugging messages' +end + +@order = opts[:order] +@verbose = opts[:verbose] +@do_list = opts[:list] +@do_prune = opts[:prune] +@do_prune_force = opts[:prune_force] +@minpercent = opts[:minpercent] +@maxpercent = opts[:maxpercent] + +@do_prune = true if @do_prune_force + +fatal('Must specify one of --prune or --list') unless + (@do_prune || @do_list) && + !(@do_prune && @do_list) + +fatal("Must specify --order as 'age' or 'importance'") unless + @order == 'age' || @order == 'importance' + +if BackupDirectory.all.empty? + fatal('No backup directories found, need to run byteback-snapshot') +end + +lock_out_other_processes('byteback-prune') + +@df_history = DiskFreeHistory.new(ENV['HOME']) +begin + @df_history.new_reading! +rescue Errno::ENOSPC + if @do_list + warn("Couldn't write disk history file due to lack of space, ignoring") + else + warn("Couldn't write disk history file due to lack of space, going to --prune-force") + @do_prune = @do_prune_force = true + end +rescue => anything_else + error("Couldn't record disk history of #{@df_history.mountpoint} in #{@df_history.history_file}, installation problem?") + raise +end + +gradient_30m = @df_history.gradient(1800) + +# Check whether we should still be pruning +# +@free = @df_history.list.last.percent_free +PRUNING_FLAG = "#{ENV['HOME']}/.byteback.pruning" + +if @do_prune_force + info('Forcing prune') +elsif @free <= @minpercent && !File.exist?(PRUNING_FLAG) + info("Starting prune #{@free}% -> #{@maxpercent} free") + File.write(PRUNING_FLAG, '') +elsif @free >= @maxpercent && File.exist?(PRUNING_FLAG) + info("Stopping prune, reached #{@free}% free") + File.unlink(PRUNING_FLAG) +elsif File.exist?(PRUNING_FLAG) + info("Continuing prune #{@free}% -> #{@maxpercent}, gradient = #{gradient_30m}") +end + +debug("Disc free #{@free}%, 30m gradient = #{gradient_30m}") + +def snapshots_in_order + list = BackupDirectory.all_snapshots + if @order == 'importance' + Snapshot.sort_by_importance(list) + elsif @order == 'age' + list.sort.reverse + else + fail ArgumentError.new("Unknown snapshot sort method #{method}") + end +end + +snapshots = snapshots_in_order + +if @do_list + print "Backups by #{@order}:\n" + snapshots.each_with_index do |snapshot, index| + print "#{sprintf('% 3d', index)}: #{snapshot.path}\n" + end +end + +# Don't do anything if we've not got two hours of readings +# +unless @do_prune_force + if @df_history.list.last.time - @df_history.list.first.time < 1800 + warn('Not enough disc space history to make a decision') + exit 0 + end +end + +exit 0 unless + (@do_prune && File.exist?(PRUNING_FLAG)) || + @do_prune_force + +exit 0 unless @do_prune_force || gradient_30m == 0 + +if snapshots.empty? + error('No snapshots to delete, is there enough disc space?') + exit 1 +end + +info("Deleting #{snapshots.last.path}") +log_system("/sbin/btrfs subvolume delete #{snapshots.last.path}") diff --git a/bin/byteback-receive b/bin/byteback-receive new file mode 100755 index 0000000..4b949c2 --- /dev/null +++ b/bin/byteback-receive @@ -0,0 +1,62 @@ +#!/usr/bin/ruby +# +# Program to receive backups and run rsync in receive mode. Must check that +# user as authorised by SSH is allowed to access particular directory. + +$LOAD_PATH << '/usr/lib/byteback' + +require 'trollop' +require 'byteback' +include Byteback::Log + +if ENV['SSH_ORIGINAL_COMMAND'] + ARGV.concat(ENV['SSH_ORIGINAL_COMMAND'].split(' ')) +end + +byteback_host = ENV['BYTEBACK_HOST'] +fatal('BYTEBACK_HOST environment not set') unless byteback_host + +byteback_root = ENV['HOME'] + '/' + ENV['BYTEBACK_HOST'] +fatal("#{byteback_root} does not exist") unless File.directory?(byteback_root) + +# +# Force restores to be limited to the hostname we're connecting form +# +if (ARGV[0] == 'restore') + ARGV[0] = 'rsync' + a = [] + ARGV.each do |tmp| + if tmp =~ /^\/(.*)/ + tmp = "#{byteback_host}/#{Regexp.last_match(1).dup}" + end + a.push(tmp) + end + exec(*a) +elsif ARGV[0] == 'rsync' + ARGV[-1] = "#{byteback_root}/current" + exec(*ARGV) +elsif ARGV[0] == 'byteback-snapshot' + ARGV.concat(['--root', "#{byteback_root}"]) + exec(*ARGV) +end + +opts = Trollop.options do + opt :verbose, 'Print diagnostics' + opt :ping, 'Check connection parameters and exit' + opt :list, 'Show backed up files matching the given pattern', type: :string + opt :restore, 'Perform a restoration operation', type: :string + opt :complete, 'Mark current backup as complete' +end + +error('Please only choose one mode') if opts[:ping] && opts[:complete] +if opts[:complete] + system('byteback-snapshot', '--root', byteback_root) +elsif opts[:list] + system("cd #{byteback_root} && find . -print | grep #{opts[:list]}") + exit(0) +elsif opts[:ping] + exit 0 +else + STDERR.print "byteback-receive failed\n" + exit 9 +end diff --git a/bin/byteback-restore b/bin/byteback-restore new file mode 100755 index 0000000..48eea9a --- /dev/null +++ b/bin/byteback-restore @@ -0,0 +1,125 @@ +#!/usr/bin/ruby +# +# Restore a file from the most recent backup, from the remote host. +# + +$LOAD_PATH.unshift('/usr/lib/byteback') +$LOAD_PATH.unshift('./lib/') + +require 'trollop' + +# +# Show an error message and abort. +# +def fatal(str) + STDERR.puts(str) + exit(1) +end + +# +# Run a remote command. +# +def ssh(*ssh_args) + args = ['ssh', + '-o', 'BatchMode=yes', + '-o', 'ConnectionAttempts=5', + '-o', 'ConnectTimeout=30', + '-o', 'ServerAliveInterval=60', + '-o', 'TCPKeepAlive=yes', + '-x', '-a', + '-i', @ssh_key, + '-l', @destination_user, + @destination_host + ] + + ssh_args + .map { |a| a ? a : '' } + + system(*args) +end + +def list_files(pattern) + ssh('byteback-receive', '--list', pattern) +end + +# +# We cannot use plain 'rsync' here because the receiver command will +# see that, and rewrite our arguments. +# +# To cater to this we have to wrap the rsync for the restore and we +# do that by setting "rsync-path" to point to the receiver program. +# +# +def restore_file(path, revision) + cmd = %w( rsync ) + cmd += ['--rsh', 'ssh -o BatchMode=yes -x -a -i /etc/byteback/key -l byteback'] + cmd += ['--rsync-path', 'restore --fake-super'] + cmd += ['-aApzrX', '--numeric-ids'] + cmd += ["#{@destination_host}:/#{revision}/#{path}", '.'] + system(*cmd) +end + +# +# Parse our command-line arguments +# +opts = Trollop.options do + banner "byteback-restore: Restore a file\n " + + opt :file, 'The file to restore', + type: :string + + opt :revision, "The version of the file to restore - default is 'latest'", + type: :string + + opt :destination, 'Backup destination (i.e. user@host:/path)', + type: :string + + opt :ssh_key, 'SSH key filename', + type: :string, + default: '/etc/byteback/key', + short: 'k' +end + +# +# Setup default destination and key. +# +@destination = File.read('/etc/byteback/destination').chomp if + File.exist?('/etc/byteback/destination') +@ssh_key = '/etc/byteback/key' if File.exist?('/etc/byteback/key') + +# +# Allow the command-line to override them. +# +@ssh_key = opts[:ssh_key] unless opts[:ssh_key].nil? +@destination = opts[:destination] unless opts[:destination].nil? + +# +# Check our destination is well-formed +# +if @destination =~ /^(?:(.+)@)?([^@:]+):(.+)?$/ + @destination_user, @destination_host, @destination_path = [Regexp.last_match(1), Regexp.last_match(2), Regexp.last_match(3)] +else + fatal('Destination must be a remote path, e.g. ssh@host.com:/store/backups') +end + +# +# If the user didn't specify a file then we're not restoring anything, +# and we should abort. +# +if opts[:file].nil? + fatal('You must specify a file to restore') +end + +# +# If the user specified a file, but not a revision, then we list +# the available revisions. +# +if opts[:revision].nil? + list_files(opts[:file]) + exit(0) +end + +# +# Restore a file +# +restore_file(opts[:file], opts[:revision]) +exit(0) diff --git a/bin/byteback-setup-client b/bin/byteback-setup-client new file mode 100755 index 0000000..d8ffbfa --- /dev/null +++ b/bin/byteback-setup-client @@ -0,0 +1,70 @@ +#!/usr/bin/ruby +# +# Run on a client machine to set up backups for the first time +# + +$LOAD_PATH.unshift('/usr/lib/byteback') + +require 'fileutils' +require 'trollop' +require 'byteback/util' +require 'byteback/log' +include Byteback::Util +include Byteback::Log + +def error(message) + STDERR.print "*** #{message}\n" + exit 1 +end + +def verbose(message) + print "#{message}\n" +end + +opts = Trollop.options do + opt :hostname, 'Set host name for backups', + type: :string + + opt :destination, 'Backup destination (i.e. user@host:/path)', + type: :string +end + +@destination = opts[:destination] +@hostname = opts[:hostname] + +_dummy, @destination_user, @destination_host, colon, @destination_path = + /^(.*)?(?:@)([^:]+)(:)(.*)?$/.match(@destination).to_a + +@destination_user ||= 'byteback' +@destination_path ||= '' +@destination_host ||= @destination + +unless @hostname + @hostname = `hostname -f`.chomp + warn "No hostname set, using #{@hostname}\n" +end + +FileUtils.mkdir_p('/etc/byteback') + +if File.readable?('/etc/byteback/key') + warn "Skipping key generation, delete /etc/byteback/key if that's wrong" +else + + error "Couldn't generate SSH key" unless + system <<-KEYGEN + ssh-keygen -q -t rsa -C "byteback client key" \ + -N "" -f /etc/byteback/key + KEYGEN + +end + +key_pub = File.read('/etc/byteback/key.pub').chomp + +error "Remote setup didn't work" unless + system("ssh -i /etc/byteback/key -l #{@destination_user} #{@destination_host} byteback-setup-client-receive #{@hostname} #{key_pub}") + +File.open('/etc/byteback/destination', 'w') do |f| + f.print "#{@destination_user}@#{@destination_host}:#{@destination_path}" +end + +print "Setup worked! To take your first backup run: byteback-backup --verbose\n" diff --git a/bin/byteback-setup-client-receive b/bin/byteback-setup-client-receive new file mode 100755 index 0000000..3673b6a --- /dev/null +++ b/bin/byteback-setup-client-receive @@ -0,0 +1,54 @@ +#!/usr/bin/ruby +# +# Called by byteback-setup-client to set up a new byteback-setup-client +# + +$LOAD_PATH.unshift('/usr/lib/byteback') + +require 'fileutils' +require 'trollop' +require 'byteback/util' +require 'byteback/log' +include Byteback::Util +include Byteback::Log + +def error(message) + STDERR.print "*** #{message}\n" + exit 1 +end + +@hostname = ARGV.shift +@pubkey = ARGV.join(' ') + +error('You must call this from byteback-setup-client on remote host') unless + @hostname && + /^ssh/.match(@pubkey) && + ENV['SSH_CONNECTION'] + +@client_ip = ENV['SSH_CONNECTION'].split(' ').first + +Dir.chdir(ENV['HOME']) # don't know why we wouldn't be here + +FileUtils.mkdir_p(@hostname) + +error("Couldn't create btrfs subvolume") unless + system("/sbin/btrfs subvolume create #{@hostname}/current") + +FileUtils.mkdir_p('.ssh') + +if File.exist?('.ssh/authorized_keys') && + File.read('.ssh/authorized_keys').match(@pubkey.split(/\s+/)[1]) + + warn('This key already exists in .ssh/authorized_keys on server, nothing to do!') + +else + + File.open('.ssh/authorized_keys', 'a+') do |fh| + fh.print <<-LINE.gsub(/\n/, '') + "\n" +command="byteback-receive", +from="#{@client_ip}", +environment="BYTEBACK_HOST=#{@hostname}" + #{@pubkey} + LINE + end +end diff --git a/bin/byteback-snapshot b/bin/byteback-snapshot new file mode 100755 index 0000000..fc9aab3 --- /dev/null +++ b/bin/byteback-snapshot @@ -0,0 +1,40 @@ +#!/usr/bin/ruby +# +# Program to create a snapshot and/or rotate a directory of backup snapshots +# using btrfs subvolume commands. +# + +$LOAD_PATH.unshift('/usr/lib/byteback') + +require 'trollop' +require 'byteback' +include Byteback +include Byteback::Log + +opts = Trollop.options do + opt :root, 'Backups directory (must be a btrfs subvolume)', + type: :string + + opt :snapshot, '(ignored for compatibility)' + + opt :verbose, 'Print diagnostics' +end + +@root = opts[:root] +@verbose = opts[:verbose] + +fatal('--root not readable') unless File.directory?("#{@root}") + +@backups = BackupDirectory.new(@root) +snapshots = @backups.snapshots + +unless snapshots.empty? + last_snapshot_time = snapshots.last.time + fatal('Last snapshot was less than six hours ago') unless + !last_snapshot_time || + Time.now - last_snapshot_time >= 6 * 60 * 60 # FIXME: make configurable +end + +info 'Making new snapshot' +@backups.new_snapshot! +info 'Finished' |