NAS Backups, Part 2: Btrfs send / receive
Hey there! In the first post of this series, I talked about my plan for a backup NAS to complement my main NAS. In this part, I'm going to show the pair of scripts I've developed to take care of backing up btrfs snapshots.
The first script is called snapshot-send.sh
, and it:
- Calculates which snapshot it is that requires sending
- Uses SSH to remote into the backup NAS
- Pipes the output of
btrfs send
to snapshot-receive.sh
on the backup NAS that is called with sudo
Note there that while sudo
is used for calling snapshot-receive.sh
, the account it uses to SSH into the backup NAS, it doesn't have completely unrestricted sudo
access. Instead, a sudo
rule is used to restrict it to allow only specific commands to be called (without a password, as this is intended to be a completely automated and unattended system).
The second script is called snapshot-receive.sh
, and it receives the output of btrfs send
and pipes it to btrfs receive
. It also has some extra logic to delete old snapshots and stuff like that.
Both of these are designed to be command line programs in their own right with a simple CLI, and useful error / help messages to assist in understanding it when I come back to it to fix an issue or extend it after many months.
snapshot-send.sh
As described above, snapshot-send.sh sends btrfs snapshot to a remote host via SSH and the snapshot-receive.sh
script.
Before we continue and look at it in detail, it is important to note that snapshot-send.sh
depends on btrfs-snapshot-rotation
. If you haven't already done so, you should set that up first before setting up my scripts here.
If you have btrfs-snapshot-rotation
setup correctly, you should have something like this in your crontab:
# Btrfs automatic snapshots
0 * * * * cronic /root/btrfs-snapshot-rotation/btrfs-snapshot /mnt/some_btrfs_filesystem/main /mnt/some_btrfs_filesystem/main/.snapshots hourly 8
0 2 * * * cronic /root/btrfs-snapshot-rotation/btrfs-snapshot /mnt/some_btrfs_filesystem/main /mnt/some_btrfs_filesystem/main/.snapshots daily 4
0 2 * * 7 cronic /root/btrfs-snapshot-rotation/btrfs-snapshot /mnt/some_btrfs_filesystem/main /mnt/some_btrfs_filesystem/main/.snapshots weekly 4
I use cronic
there to reduce unnecessary emails. I also have a subvolume there for the snapshots:
sudo btrfs subvolume create /mnt/some_btrfs_filesystem/main/.snapshots
Because Btrfs does not take take a snapshot of any child subvolumes when it takes a snapshot, I can use this to keep all my snapshots organised and associated with the subvolume they are snapshots of.
If done right, ls /mnt/some_btrfs_filesystem/main/.snapshots
should result in something like this:
2021-07-25T02:00:01+00:00-@weekly 2021-08-17T07:00:01+00:00-@hourly
2021-08-01T02:00:01+00:00-@weekly 2021-08-17T08:00:01+00:00-@hourly
2021-08-08T02:00:01+00:00-@weekly 2021-08-17T09:00:01+00:00-@hourly
2021-08-14T02:00:01+00:00-@daily 2021-08-17T10:00:01+00:00-@hourly
2021-08-15T02:00:01+00:00-@daily 2021-08-17T11:00:01+00:00-@hourly
2021-08-15T02:00:01+00:00-@weekly 2021-08-17T12:00:01+00:00-@hourly
2021-08-16T02:00:01+00:00-@daily 2021-08-17T13:00:01+00:00-@hourly
2021-08-17T02:00:01+00:00-@daily [email protected]
2021-08-17T06:00:01+00:00-@hourly
Ignore the [email protected]
there for now - it's created by snapshot-send.sh
so that it can remember the name of the snapshot it last sent. We'll talk about it later.
With that out of the way, let's start going through snapshot-send.sh
! First up is the CLI and associated error handling:
#!/usr/bin/env bash
set -e;
dir_source="${1}";
tag_source="${2}";
tag_dest="${3}";
loc_ssh_key="${4}";
remote_host="${5}";
if [[ -z "${remote_host}" ]]; then
echo "This script sends btrfs snapshots to a remote host via SSH.
The script snapshot-receive must be present on the remote host in the PATH for this to work.
It pairs well with btrfs-snapshot-rotation: https://github.com/mmehnert/btrfs-snapshot-rotation
Usage:
snapshot-send.sh <snapshot_dir> <source_tag_name> <dest_tag_name> <ssh_key> <[email protected]>
Where:
<snapshot_dir> is the path to the directory containing the snapshots
<source_tag_name> is the tag name to look for (see btrfs-snapshot-rotation).
<dest_tag_name> is the tag name to use when sending to the remote. This must be unique across all snapshot rotations sent.
<ssh_key> is the path to the ssh private key
<[email protected]> is the user@host to connect to via SSH" >&2;
exit 0;
fi
# $EUID = effective uid
if [[ "${EUID}" -ne 0 ]]; then
echo "Error: This script must be run as root (currently running as effective uid ${EUID})" >&2;
exit 5;
fi
if [[ ! -e "${loc_ssh_key}" ]]; then
echo "Error: When looking for the ssh key, no file was found at '${loc_ssh_key}' (have you checked the spelling and file permissions?)." >&2;
exit 1;
fi
if [[ ! -d "${dir_source}" ]]; then
echo "Error: No source directory located at '${dir_source}' (have you checked the spelling and permissions?)" >&2;
exit 2;
fi
###############################################################################
Pretty simple stuff. snapshot-send.sh
is called like so:
snapshot-send.sh /absolute/path/to/snapshot_dir SOURCE_TAG DEST_TAG_NAME path/to/ssh_key [email protected]
A few things to unpack here.
/absolute/path/to/snapshot_dir
is the path to the directory (i.e. btrfs subvolume) containing the snapshots we want to read, as described above.
SOURCE_TAG
: Given the directory (subvolume) name of a snapshot (e.g. 2021-08-17T02:00:01+00:00-@daily
), then the source tag is the bit at the end after the at sign @
- e.g. daily
.
DEST_TAG_NAME
: The tag name to give the snapshot on the backup NAS. Useful, because you might have multiple subvolumes you snapshot with btrfs-snapshot-rotation and they all might have snapshots with the daily
tag.
path/to/ssh_key
: The path to the (unencrypted!) SSH key to use to SSH into the remote backup NAS.
[email protected]
: The user and hostname of the backup NAS to SSH into.
This is a good time to sort out the remote user we're going to SSH into (we'll sort out snapshot-receive.sh
and the sudo rules in the next section below).
Assuming that you already have a Btrfs filesystem setup and automounting on boot on the remote NAS, do this:
sudo useradd --system --home /absolute/path/to/btrfs-filesystem/backups backups
sudo groupadd backup-senders
sudo usermod -a -G backup-senders backups
cd /absolute/path/to/btrfs-filesystem/backups
sudo mkdir .ssh
sudo touch .ssh/authorized_keys
sudo chown -R backups:backups .ssh
sudo chmod -R u=rwX,g=rX,o-rwx .ssh
Then, on the main NAS, generate the SSH key:
mkdir -p /root/backups && cd /root/backups
ssh-keygen -t ed25519 -C backups@main-nas -f /root/backups/ssh_key_backup_nas_ed25519
Then, copy the generated SSH public key to the authorized_keys
file on the backup NAS (located at /absolute/path/to/btrfs-filesystem/backups/.ssh/authorized_keys
).
Now that's sorted, let's continue with snapshot-send.sh
. Next up are a few miscellaneous functions:
# The filepath to the last sent text file that contains the name of the snapshot that was last sent to the remote.
# If this file doesn't exist, then we send a full snapshot to start with.
# We need to keep track of this because we need this information to know which
# snapshot we need to parent the latest snapshot from to send snapshots incrementally.
filepath_last_sent="${dir_source}/last_sent_@${tag_source}.txt";
## Logs a message to stderr.
# $* The message to log.
log_msg() {
echo "[ $(date +%Y-%m-%dT%H:%M:%S) ] remote/${HOSTNAME}: >>> ${*}";
}
## Lists all the currently available snapshots for the current source tag.
list_snapshots() {
find "${dir_source}" -maxdepth 1 ! -path "${dir_source}" -name "*@${tag_source}" -type d;
}
## Returns an exit code of 0 if we've sent a snapshot, or 1 if we haven't.
have_sent() {
if [[ ! -f "${filepath_last_sent}" ]]; then
return 1;
else
return 0;
fi
}
## Fetches the directory name of the last snapshot sent to the remote with the given tag name.
last_sent() {
if [[ -f "${filepath_last_sent}" ]]; then
cat "${filepath_last_sent}";
fi
}
# Runs snapshot-receive on the remote host.
do_ssh() {
ssh -o "ServerAliveInterval=900" -i "${loc_ssh_key}" "${remote_host}" sudo snapshot-receive "${tag_dest}";
}
Particularly of note is the filepath_last_sent
variable - this is set to the path to that text file I mentioned earlier.
Other than that it's all pretty well commented, so let's continue on. Next, we need to determine the name of the latest snapshot:
latest_snapshot="$(list_snapshots | sort | tail -n1)";
latest_snapshot_dirname="$(dirname "${latest_snapshot}")";
With this information in hand we can compare it to the last snapshot name we sent. We store this in the text file mentioned above - the path to which is stored in the filepath_last_sent
variable.
if [[ "$(dirname "${latest_snapshot_dirname}")" == "$(cat "${filepath_last_sent}")" ]]; then
if [[ -z "${FORCE_SEND}" ]]; then
echo "We've sent the latest snapshot '${latest_snapshot_dirname}' already and the FORCE_SEND environment variable is empty or not specified, skipping";
exit 0;
else
echo "We've sent it already, but sending it again since the FORCE_SEND environment variable is specified";
fi
fi
If the latest snapshot has the same name as the one we last send, we exit out - unless the FORCE_SEND
environment variable is specified (to allow for an easy way to fix stuff if it goes wrong on the other end).
Now, we can actually send the snapshot to the remote:
if ! have_sent; then
log_msg "Sending initial snapshot $(dirname "${latest_snapshot}")";
btrfs send "${latest_snapshot}" | do_ssh;
else
parent_snapshot="${dir_source}/$(last_sent)";
if [[ ! -d "${parent_snapshot}" ]]; then
echo "Error: Failed to locate parent snapshot at '${parent_snapshot}'" >&2;
exit 3;
fi
log_msg "Sending incremental snapshot $(dirname "${latest_snapshot}") parent $(last_sent)";
btrfs send -p "${parent_snapshot}" "${latest_snapshot}" | do_ssh;
fi
have_sent
simply determines if we have previously sent a snapshot before. We know this by checking the filepath_last_sent
text file.
If we haven't, then we send a full snapshot rather than an incremental one. If we're sending an incremental one, then we find the parent snapshot (i.e. the one we last sent). If we can't find it, we generate an error (it's because of this that you need to store at least 2 snapshots at a time with btrfs-snapshot-rotation).
After sending a snapshot, we need to update the filepath_last_sent
text file:
log_msg "Updating state information";
basename "${latest_snapshot}" >"${filepath_last_sent}";
log_msg "Snapshot sent successfully";
....and that concludes snapshot-send.sh
! Once you've finished reading this blog post and testing your setup, put your snapshot-send.sh
calls in a script in /etc/cron.daily
or something.
snapshot-receive.sh
Next up is the receiving end of the system. The CLI for this script is much simpler, on account of sudo rules only allowing exact and specific commands (no wildcards or regex of any kind). I put snapshot-receive.sh
in /usr/local/sbin
and called it snapshot-receive
.
Let's get started:
#!/usr/bin/env bash
# This script wraps btrfs receive so that it can be called by non-root users.
# It should be saved to '/usr/local/sbin/snapshot-receive' (without quotes, of course).
# The following entry needs to be put in the sudoers file:
#
# %backup-senders ALL=(ALL) NOPASSWD: /usr/local/sbin/snapshot-receive TAG_NAME
#
# ....replacing TAG_NAME with the name of tag you want to allow. You'll need 1 line in your sudoers file per tag you want to allow.
# Edit your sudoers file like this:
# sudo visudo
# The ABSOLUTE path to the target directory to receive to.
target_dir="CHANGE_ME";
# The maximum number of backups to keep.
max_backups="7";
# Allow only alphanumeric characters in the tag
tag="$(echo "${1}" | tr -cd '[:alnum:]-_')";
snapshot-receive.sh
only takes a single argument, and that's the tag it should use for the snapshot being received:
sudo snapshot-receive DEST_TAG_NAME
The target directory it should save snapshots to is stored as a variable at the top of the file (the target_dir
there). You should change this based on your specific setup. It goes without saying, but the target directory needs to be a directory on a btrfs filesystem (preferable raid1, though as I've said before btrfs raid1 is a misnomer). We also ensure that the tag contains only safe characters for security.
max_backups
is the maximum number of snapshots to keep. Any older snapshots will be deleted.
Next, ime error handling:
###############################################################################
# $EUID = effective uid
if [[ "${EUID}" -ne 0 ]]; then
echo "Error: This script must be run as root (currently running as effective uid ${EUID})" >&2;
exit 5;
fi
if [[ -z "${tag}" ]]; then
echo "Error: No tag specified. It should be specified as the 1st and only argument, and may only contain alphanumeric characters." >&2;
echo "Example:" >&2;
echo " snapshot-receive TAG_NAME_HERE" >&2;
exit 4;
fi
Nothing too exciting. Continuing on, a pair of useful helper functions:
###############################################################################
## Logs a message to stderr.
# $* The message to log.
log_msg() {
echo "[ $(date +%Y-%m-%dT%H:%M:%S) ] remote/${HOSTNAME}: >>> ${*}";
}
list_backups() {
find "${target_dir}/${tag}" -maxdepth 1 ! -path "${target_dir}/${tag}" -type d;
}
list_backups
lists the snapshots with the given tag, and log_msg
logs messages to stdout
(not stderr
unless there's an error, because otherwise cronic
will dutifully send you an email every time the scripts execute). Next up, more error handling:
###############################################################################
if [[ "${target_dir}" == "CHANGE_ME" ]]; then
echo "Error: target_dir was not changed from the default value." >&2;
exit 1;
fi
if [[ ! -d "${target_dir}" ]]; then
echo "Error: No directory was found at '${target_dir}'." >&2;
exit 2;
fi
if [[ ! -d "${target_dir}/${tag}" ]]; then
log_msg "Creating new directory at ${target_dir}/${tag}";
mkdir "${target_dir}/${tag}";
fi
We check:
- That the target directory was changed from the default
CHANGE_ME
value
- That the target directory exists
We also create a subdirectory for the given tag if it doesn't exist already.
With the preamble completed, we can actually receive the snapshot:
log_msg "Launching btrfs in chroot mode";
time nice ionice -c Idle btrfs receive --chroot "${target_dir}/${tag}";
We use nice
and ionice
to reduce the priority of the receive to the lowest possible level. If you're using a Raspberry Pi (I have a Raspberry Pi 4 with 4GB RAM) like I am, this is important for stability (Pis tend to fall over otherwise). Don't worry if you experience some system crashes on your Pi when transferring the first snapshot - I've found that incremental snapshots don't cause the same issue.
We also use the chroot
option there for increased security.
Now that the snapshot is transferred, we can delete old snapshots if we have too many:
backups_count="$(echo -e "$(list_backups)" | wc -l)";
log_msg "Btrfs finished, we now have ${backups_count} backups:";
list_backups;
while [[ "${backups_count}" -gt "${max_backups}" ]]; do
oldest_backup="$(list_backups | sort | head -n1)";
log_msg "Maximum number backups is ${max_backups}, requesting removal of backup for $(dirname "${oldest_backup}")";
btrfs subvolume delete "${oldest_backup}";
backups_count="$(echo -e "$(list_backups)" | wc -l)";
done
log_msg "Done, any removed backups will be deleted in the background";
Sorted! The only thing left to do here is to setup those sudo
rules. Let's do that now. Execute sudoedit /etc/sudoers
, and enter the following:
%backup-senders ALL=(ALL) NOPASSWD: /usr/local/sbin/snapshot-receive TAG_NAME
Replace TAG_NAME
with the DEST_TAG_NAME
you're using. You'll need 1 entry in /etc/sudoers
for each DEST_TAG_NAME
you're using.
We assign the rights to the backup-senders
group we created earlier, of which the user we are going to SSH in with is a member. This make the system more flexible should we want to extend it later.
Warning: A mistake in /etc/sudoers
can leave you unable to use sudo
! Make sure you have a root shell open in the background and that you test sudo
again after making changes to ensure you haven't made a mistake.
That completes the setup of snapshot-receive.sh
.
Conclusion
With snapshot-send.sh
and snapshot-receive.sh
, we now have a system for transferring snapshots from 1 host to another via SSH. If combined with full disk encryption (e.g. with LUKS), this provides a secure backup system with a number of desirable qualities:
- The main NAS can't access the backups on the backup NAS (in case fo ransomware)
- Backups are encrypted during transfer (via SSH)
- Backups are encrypted at rest (LUKS)
To further secure the backup NAS, one could:
- Disable SSH password login
- Automatically start / shutdown the backup NAS (though with full disk encryption when it boots up it would require manual intervention)
At the bottom of this post I've included the full scripts for you to copy and paste.
As it turns out, there will be 1 more post in this series, which will cover generating multiple streams of backups (e.g. weekly, monthly) from a single stream of e.g. daily backups on my backup NAS.
Sources and further reading
Full scripts
snapshot-send.sh
#!/usr/bin/env bash
set -e;
dir_source="${1}";
tag_source="${2}";
tag_dest="${3}";
loc_ssh_key="${4}";
remote_host="${5}";
if [[ -z "${remote_host}" ]]; then
echo "This script sends btrfs snapshots to a remote host via SSH.
The script snapshot-receive must be present on the remote host in the PATH for this to work.
It pairs well with btrfs-snapshot-rotation: https://github.com/mmehnert/btrfs-snapshot-rotation
Usage:
snapshot-send.sh <snapshot_dir> <source_tag_name> <dest_tag_name> <ssh_key> <[email protected]>
Where:
<snapshot_dir> is the path to the directory containing the snapshots
<source_tag_name> is the tag name to look for (see btrfs-snapshot-rotation).
<dest_tag_name> is the tag name to use when sending to the remote. This must be unique across all snapshot rotations sent.
<ssh_key> is the path to the ssh private key
<[email protected]> is the user@host to connect to via SSH" >&2;
exit 0;
fi
# $EUID = effective uid
if [[ "${EUID}" -ne 0 ]]; then
echo "Error: This script must be run as root (currently running as effective uid ${EUID})" >&2;
exit 5;
fi
if [[ ! -e "${loc_ssh_key}" ]]; then
echo "Error: When looking for the ssh key, no file was found at '${loc_ssh_key}' (have you checked the spelling and file permissions?)." >&2;
exit 1;
fi
if [[ ! -d "${dir_source}" ]]; then
echo "Error: No source directory located at '${dir_source}' (have you checked the spelling and permissions?)" >&2;
exit 2;
fi
###############################################################################
# The filepath to the last sent text file that contains the name of the snapshot that was last sent to the remote.
# If this file doesn't exist, then we send a full snapshot to start with.
# We need to keep track of this because we need this information to know which
# snapshot we need to parent the latest snapshot from to send snapshots incrementally.
filepath_last_sent="${dir_source}/last_sent_@${tag_source}.txt";
## Logs a message to stderr.
# $* The message to log.
log_msg() {
echo "[ $(date +%Y-%m-%dT%H:%M:%S) ] remote/${HOSTNAME}: >>> ${*}";
}
## Lists all the currently available snapshots for the current source tag.
list_snapshots() {
find "${dir_source}" -maxdepth 1 ! -path "${dir_source}" -name "*@${tag_source}" -type d;
}
## Returns an exit code of 0 if we've sent a snapshot, or 1 if we haven't.
have_sent() {
if [[ ! -f "${filepath_last_sent}" ]]; then
return 1;
else
return 0;
fi
}
## Fetches the directory name of the last snapshot sent to the remote with the given tag name.
last_sent() {
if [[ -f "${filepath_last_sent}" ]]; then
cat "${filepath_last_sent}";
fi
}
do_ssh() {
ssh -o "ServerAliveInterval=900" -i "${loc_ssh_key}" "${remote_host}" sudo snapshot-receive "${tag_dest}";
}
latest_snapshot="$(list_snapshots | sort | tail -n1)";
latest_snapshot_dirname="$(dirname "${latest_snapshot}")";
if [[ "$(dirname "${latest_snapshot_dirname}")" == "$(cat "${filepath_last_sent}")" ]]; then
if [[ -z "${FORCE_SEND}" ]]; then
echo "We've sent the latest snapshot '${latest_snapshot_dirname}' already and the FORCE_SEND environment variable is empty or not specified, skipping";
exit 0;
else
echo "We've sent it already, but sending it again since the FORCE_SEND environment variable is specified";
fi
fi
if ! have_sent; then
log_msg "Sending initial snapshot $(dirname "${latest_snapshot}")";
btrfs send "${latest_snapshot}" | do_ssh;
else
parent_snapshot="${dir_source}/$(last_sent)";
if [[ ! -d "${parent_snapshot}" ]]; then
echo "Error: Failed to locate parent snapshot at '${parent_snapshot}'" >&2;
exit 3;
fi
log_msg "Sending incremental snapshot $(dirname "${latest_snapshot}") parent $(last_sent)";
btrfs send -p "${parent_snapshot}" "${latest_snapshot}" | do_ssh;
fi
log_msg "Updating state information";
basename "${latest_snapshot}" >"${filepath_last_sent}";
log_msg "Snapshot sent successfully";
snapshot-receive.sh
#!/usr/bin/env bash
# This script wraps btrfs receive so that it can be called by non-root users.
# It should be saved to '/usr/local/sbin/snapshot-receive' (without quotes, of course).
# The following entry needs to be put in the sudoers file:
#
# %backup-senders ALL=(ALL) NOPASSWD: /usr/local/sbin/snapshot-receive TAG_NAME
#
# ....replacing TAG_NAME with the name of tag you want to allow. You'll need 1 line in your sudoers file per tag you want to allow.
# Edit your sudoers file like this:
# sudo visudo
# The ABSOLUTE path to the target directory to receive to.
target_dir="CHANGE_ME";
# The maximum number of backups to keep.
max_backups="7";
# Allow only alphanumeric characters in the tag
tag="$(echo "${1}" | tr -cd '[:alnum:]-_')";
###############################################################################
# $EUID = effective uid
if [[ "${EUID}" -ne 0 ]]; then
echo "Error: This script must be run as root (currently running as effective uid ${EUID})" >&2;
exit 5;
fi
if [[ -z "${tag}" ]]; then
echo "Error: No tag specified. It should be specified as the 1st and only argument, and may only contain alphanumeric characters." >&2;
echo "Example:" >&2;
echo " snapshot-receive TAG_NAME_HERE" >&2;
exit 4;
fi
###############################################################################
## Logs a message to stderr.
# $* The message to log.
log_msg() {
echo "[ $(date +%Y-%m-%dT%H:%M:%S) ] remote/${HOSTNAME}: >>> ${*}";
}
list_backups() {
find "${target_dir}/${tag}" -maxdepth 1 ! -path "${target_dir}/${tag}" -type d;
}
###############################################################################
if [[ "${target_dir}" == "CHANGE_ME" ]]; then
echo "Error: target_dir was not changed from the default value." >&2;
exit 1;
fi
if [[ ! -d "${target_dir}" ]]; then
echo "Error: No directory was found at '${target_dir}'." >&2;
exit 2;
fi
if [[ ! -d "${target_dir}/${tag}" ]]; then
log_msg "Creating new directory at ${target_dir}/${tag}";
mkdir "${target_dir}/${tag}";
fi
log_msg "Launching btrfs in chroot mode";
time nice ionice -c Idle btrfs receive --chroot "${target_dir}/${tag}";
backups_count="$(echo -e "$(list_backups)" | wc -l)";
log_msg "Btrfs finished, we now have ${backups_count} backups:";
list_backups;
while [[ "${backups_count}" -gt "${max_backups}" ]]; do
oldest_backup="$(list_backups | sort | head -n1)";
log_msg "Maximum number backups is ${max_backups}, requesting removal of backup for $(dirname "${oldest_backup}")";
btrfs subvolume delete "${oldest_backup}";
backups_count="$(echo -e "$(list_backups)" | wc -l)";
done
log_msg "Done, any removed backups will be deleted in the background";