snapsh/snapsh

540 lines
16 KiB
Bash
Executable File

#!/bin/bash
## snapsh
## Copyright (C) 2020 Jarno Rankinen
##
## This program is free software: you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation, either version 3 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License
## along with this program. If not, see <http://www.gnu.org/licenses/>.
# Environment set up:
SNAPSH_VERSION='0.2.2'
# If config file exists, source it, otherwise use default values
if [[ -e /etc/snapsh.conf ]]; then
. /etc/snapsh.conf
else
TOPLEVEL="/root/btrfs-toplevel" # Mountpoint of subvolid=6
SNAPSHOTS_LOCATION="/root/btrfs-toplevel/snapshots" # Mountpoint of subvolume for snapshots
TIMESTAMP="$(date +%Y.%m.%d-%H.%M.%S)" # Timestamp used in naming snapshots
fi # (Not the one used in --list)
exit() {
if [[ "$SNAPSH_MOUNT" != "true" ]]; then
umount ${TOPLEVEL} || builtin exit 23
rmdir ${TOPLEVEL}
fi
builtin exit $1
}
## In case of problems, define the path to the 'btrfs' executable here
BTRFS_EXECUTABLE=$(which btrfs)
SUBVOLUME="root" # Default subvolume
DESCRIPTION="" # Description is blank unless set with the -d|--description option
help() {
printf "Usage:\n
snapsh [OPTIONS]
Options:
-h, --help Display this help message
-s SUBVOL, --snapshot SUBVOL Take a snapshot of subvolume named SUBVOL.
-d STR, --description STR Add a description for the snapshot displayed
in the snapshots listing.
-t TYPE, --type TYPE Set the type of snapshot, where
TYPE=manual|auto|boot|backup
Can be used with --list to filter results
-l, --list List snapshots
-r NUMBER, --remove NUMBER Remove snapshot NUMBER. See snapshot numbers
with snapsh -l
--rollback NUMBER Roll back to snapshot NUMBER. See snapshot
numbers with snapsh -l. Target subvolume is
detected from snapshot automatically.
--mount Mount the top level subvolume to /root/btrfs-toplevel
--umount Unmount the top level subvolume
\n"
}
mount_check() {
# Check that the toplevel subvolume (id=5) is mounted, and if not,
# mount it to path defined in $TOPLEVEL
if ! mount | grep subvolid=5 | grep "${TOPLEVEL}" &> /dev/null; then
# Get the UUID of the current btrfs volume
MOUNT_UUID=$(btrfs filesystem show | grep uuid | cut -d ':' -f 3)
# Create the mountpoint for the toplevel if needed
[[ -d ${TOPLEVEL} ]] || mkdir -p ${TOPLEVEL}
# Mount the toplevel
mount -U ${MOUNT_UUID} -o subvolid=5 ${TOPLEVEL}
fi
# Check that the subvolume for storing snapshots exists, and if not,
# ask to create it
if ! ${BTRFS_EXECUTABLE} subvolume show ${SNAPSHOTS_LOCATION} &> /dev/null; then
printf "Subvolume ${SNAPSHOTS_LOCATION} does not exist. Create it now? "
read -n 1 -p "y/n: "
if [[ "${REPLY}" == "y" ]]; then
printf "\n"
# Create subvolume defined with SNAPSHOTS_LOCATION
${BTRFS_EXECUTABLE} subvolume create ${SNAPSHOTS_LOCATION}
unset ${REPLY}
else
printf "\n$0 needs the 'snapshots' subvolume to operate. Please see README.md\n"
exit 1
fi
fi
}
snapshot() {
EXIT_CODE=0
root_check #Check that script is run with root privileges.
mount_check #Check that the toplevel subvolume is mounted
# TYPE defaults to manual
if [[ -z "${SET_TYPE}" ]]; then
SET_TYPE="manual"
fi
printf "Creating snapshot of subvolume ${SUBVOLUME} as \
${SUBVOLUME}_snapshot_${TIMESTAMP}\n"
# Create info file for listing snapshots
# Created first on the source subvolume, then deleted from the source
printf "DATE=\"$(date)\"
SOURCE_SUBVOLUME=\"${SUBVOLUME}\"
DESCRIPTION=\"${DESCRIPTION}\"
TYPE=\"${SET_TYPE}\"\n" > ${TOPLEVEL}/${SUBVOLUME}/.snapsh
# Create readonly subvolume
${BTRFS_EXECUTABLE} subvolume snapshot -r ${TOPLEVEL}/${SUBVOLUME} \
${SNAPSHOTS_LOCATION}/${SUBVOLUME}_snapshot_${TIMESTAMP}
let EXIT_CODE=${EXIT_CODE}+$?
# Delete info file from source
rm -f ${TOPLEVEL}/${SUBVOLUME}/.snapsh
printf "Snapshot created!
${SUBVOLUME}_snapshot_${TIMESTAMP}
DATE=$(date)
SOURCE_SUBVOLUME=${SUBVOLUME}
DESCRIPTION=${DESCRIPTION}
TYPE=\"${SET_TYPE}\"\n"
exit ${EXIT_CODE}
}
list() {
root_check
mount_check
shopt -s nullglob
NUM=1
SNAPSHOTS=(${SNAPSHOTS_LOCATION}/*/)
if [[ "${#SNAPSHOTS[@]}" -eq 0 ]]; then
printf "No snapshots managed by snapsh found.\n"
exit 0
fi
printf "%6s %s %s %26s %10s %s %6s %s %s\n" "Number" "|" "Time:" "|" \
"Source" "|" "Type" "|" "Description"
for snapshot in ${SNAPSHOTS[@]}; do
if [[ -z "${SET_TYPE}" ]]; then
. ${snapshot}/.snapsh
printf "%8s %32s %12s %8s %s\n" "${NUM} |" "${DATE} |" \
"${SOURCE_SUBVOLUME} |" "${TYPE} |" "${DESCRIPTION}"
elif [[ -n "${SET_TYPE}" ]]; then
. ${snapshot}/.snapsh
[[ "${SET_TYPE}" == "${TYPE}" ]] && printf "%8s %32s %12s %8s %s\n" \
"${NUM} |" "${DATE} |" "${SOURCE_SUBVOLUME} |" "${TYPE} |" \
"${DESCRIPTION}"
fi
let NUM=NUM+1
done
exit 0
}
remove() {
root_check
mount_check
# List snapshots in to array SNAPSHOTS
SNAPSHOTS=(${SNAPSHOTS_LOCATION}/*/)
if [[ ! "$REMOVE_TARGETS" =~ [,-] ]] && [[ ! "$REMOVE_TARGETS" =~ [^0-9] ]]; then
REMOVE_TARGET="$REMOVE_TARGETS"
elif [[ "$REMOVE_TARGETS" =~ [,] ]]; then
REMOVE_TARGETS=( ${REMOVE_TARGETS//,/ } ) #$(printf $REMOVE_TARGETS | sed 's/,/ /g') )
elif [[ "$REMOVE_TARGETS" =~ [-] ]]; then
REMOVE_TARGETS=${REMOVE_TARGETS/-/..}
REMOVE_TARGETS=( $(eval echo {${REMOVE_TARGETS}}) )
fi
if [[ ! -z "$REMOVE_TARGET" ]]; then
# Check that given NUMBER is a valid snapshot
if [[ "${REMOVE_TARGET}" -gt "${#SNAPSHOTS[@]}" ]]; then
printf "Snapshot number ${REMOVE_TARGET} does not exist.\n"
exit 1
elif [[ "${REMOVE_TARGET}" -lt 1 ]]; then
printf "Number must be greater than 0.\n"
exit 1
fi
let INDEX=${REMOVE_TARGET}-1
TARGET=${SNAPSHOTS[${INDEX}]}
. ${TARGET}/.snapsh
printf "Delete snapshot ${REMOVE_TARGET}: ${DATE}, source subvolume ${SOURCE_SUBVOLUME}, ${TYPE}, ${DESCRIPTION} (y/n)? "
read -n 1
if [[ "${REPLY}" == "y" ]]; then
printf "\n"
${BTRFS_EXECUTABLE} property set ${TARGET} ro false # Set snapshot as read-write first
${BTRFS_EXECUTABLE} subvolume delete ${TARGET} # Delete snapshot
exit 0
else
printf "\nAborted by user.\n"
exit 1
fi
else
printf "You are about to delete the following snapshots:\n"
for ((i = ${#REMOVE_TARGETS[@]}-1 ; i >= 0 ; i--)); do
let INDEX=${REMOVE_TARGETS[${i}]}-1
TARGET=${SNAPSHOTS[${INDEX}]}
. ${TARGET}/.snapsh
printf "+ $(($INDEX + 1)):\t${DATE}, source subvolume ${SOURCE_SUBVOLUME},\t${TYPE},\t${DESCRIPTION}\n"
done
printf "Are you sure? (yes) "
read
if [[ "$REPLY" == "yes" ]]; then
for ((i = ${#REMOVE_TARGETS[@]}-1 ; i >= 0 ; i--)); do
let INDEX=${REMOVE_TARGETS[${i}]}-1
TARGET=${SNAPSHOTS[${INDEX}]}
. ${TARGET}/.snapsh
${BTRFS_EXECUTABLE} property set ${TARGET} ro false # Set snapshot as read-write first
${BTRFS_EXECUTABLE} subvolume delete ${TARGET} # Delete snapshot
done
fi
exit
fi
}
rollback() {
root_check # Check root privileges
mount_check # Check that the toplevel subvolume is mounted
SNAPSHOTS=(${SNAPSHOTS_LOCATION}/*/) # List snapshots to array
# Check that NUBER to roll back to is a valid snapshot
if [[ "${ROLLBACK_TARGET}" -gt "${#SNAPSHOTS[@]}" ]]; then
printf "Snapshot number ${ROLLBACK_TARGET} does not exist.\n"
exit 1
elif [[ "${ROLLBACK_TARGET}" -lt 1 ]]; then
printf "Number must be greater than 0.\n"
exit 1
fi
let INDEX=${ROLLBACK_TARGET}-1
TARGET=${SNAPSHOTS[${INDEX}]}
. ${TARGET}/.snapsh
SUBVOLUME=${SOURCE_SUBVOLUME}
printf "\nYou are about to roll back to snapshot
${ROLLBACK_TARGET}: ${DATE}
subvolume ${SOURCE_SUBVOLUME}
type ${TYPE}
description: ${DESCRIPTION}
\nAre you sure (yes/no)? "
read
if [[ "${REPLY}" == "yes" ]]; then
unset ${REPLY}
#printf "\nCreating a backup snapshot of ${SOURCE_SUBVOLUME}...\n\n"
# Create info file
printf "DATE=\"$(date)\"
SOURCE_SUBVOLUME=\"${SOURCE_SUBVOLUME}\"
DESCRIPTION=\"Rollback backup\"
TYPE=\"backup\"\n" > ${TOPLEVEL}/${SOURCE_SUBVOLUME}/.snapsh
# Create backup snapshot
printf "\nCreating snapshot of ${SUBVOLUME} as ${SNAPSHOTS_LOCATION}/${SUBVOLUME}_backup_${TIMESTAMP}...\n"
${BTRFS_EXECUTABLE} subvolume snapshot -r ${TOPLEVEL}/${SUBVOLUME} ${SNAPSHOTS_LOCATION}/${SUBVOLUME}_backup_${TIMESTAMP}
rm -f ${TOPLEVEL}/${SOURCE_SUBVOLUME}/.snapsh
printf "\n"
# Rename current subvolume
printf "Renaming ${SOURCE_SUBVOLUME} to ${SOURCE_SUBVOLUME}.previous...\n"
mv ${TOPLEVEL}/${SOURCE_SUBVOLUME} ${TOPLEVEL}/${SOURCE_SUBVOLUME}.previous
printf "Copying ${TARGET} to ${TOPLEVEL}/${SOURCE_SUBVOLUME}...\n"
${BTRFS_EXECUTABLE} subvolume snapshot ${TARGET} ${TOPLEVEL}/${SOURCE_SUBVOLUME}
rm -f ${TOPLEVEL}/${SOURCE_SUBVOLUME}/.snapsh
# Check for SElinux
if [[ $(/usr/sbin/getenforce) == "Enforcing" ]]; then
printf "\nThe system seems to have SElinux enabled. Rollbacks may cause problems with SElinux, so a relabeling is recommended.\n"
printf "Do you wish to do a relabeling after restart? (y/n) "
read -n 1
if [[ "${REPLY}" == "y" ]]; then
touch ${TOPLEVEL}/${SOURCE_SUBVOLUME}/.autorelabel
else
printf "\n\nIf you have problems after the rollback, like not being\nable to log in, add 'enforcing=0' parameter to kernel command line\n"
printf "via your bootloaders edit function or boot to a live USB\nand edit /etc/selinux/config and change 'SELINUX=enforcing' to 'SELINUX=permissive'.\n"
fi
fi
printf "\nSystem needs to be restarted. Do you wish to do that now? (recommended!) (y/n) "
read -n 1
if [[ "${REPLY}" == "y" ]]; then
systemctl reboot & exit 0
else
printf "\n\nPlease restart system as soon as possible. Any changes to subvolume ${SOURCE_SUBVOLUME} will not persist after rebooting.\n"
exit 1
fi
else
printf "\nAborted by user\n"
exit 1
fi
}
post-rollback() {
## This function is meant to be executed by the included systemd unit on every boot
## Outputs to systemd-journal, use journalctl -t snapsh to check output
EXIT_CODE=0
shopt -s nullglob
echo "Checking for leftover subvolumes..." | systemd-cat -t snapsh
mount_check
BACKUPS=(${TOPLEVEL}/*.previous/)
if [[ -n "${BACKUPS[@]}" ]]; then
for backup in "${BACKUPS[@]}"; do
echo "${backup} found" | systemd-cat -t snapsh
echo "Deleting ${backup}..." | systemd-cat -t snapsh
${BTRFS_EXECUTABLE} subvolume delete ${backup} > /dev/null
let EXIT_CODE=${EXIT_CODE}+${?}
done
echo "Deleted leftover .previous subvolumes." | systemd-cat -t snapsh
exit ${EXIT_CODE}
else
echo "No leftovers found." | systemd-cat -t snapsh
exit 0
fi
}
# Check for root permissions
root_check() {
if [[ "$UID" -ne 0 ]]; then
printf "This option needs root permission.\n"
exit 1
fi
}
version() {
echo $SNAPSH_VERSION
exit
}
# Convenience option to copy the files to correct places
install() {
INSTALL_DIR=/usr/local/bin/
root_check
SRC_DIR=${0%/*}
INSTALLED=$(command -v snapsh)
if [[ -n "$INSTALLED" ]]; then
printf "Installing from $0. Make sure you are not executing snapsh from your \$PATH!\n"
read -p "Press enter to continue or CTRL-C to stop..."
unset $REPLY
CURRENT_VERSION=$(snapsh -v)
if [[ "$CURRENT_VERSION" != "$SNAPSH_VERSION" ]]; then
printf "${INSTALL_DIR} already contains snapsh version $CURRENT_VERSION.\n"
printf "Do you want to overwrite it with version $SNAPSH_VERSION? (y/n) "
read -n1 OVERWRITE
else
printf "\nSame version of snapsh is already installed, exiting.\n"
exit 1
fi
fi
if [[ "$OVERWRITE" == "y" ]] || [[ -z "$INSTALLED" ]]; then
printf "Installing snapsh executable to $INSTALL_DIR"
cp -v $0 $INSTALL_DIR
COPYCONFIG='y'
if [[ -f /etc/snapsh.conf ]]; then
read -p -n1 "/etc/snapsh.conf already exists. Overwrite? (y/n)" COPYCONFIG
fi
[[ "$COPYCONFIG" == "y" ]] && cp -fv $SRC_DIR/snapsh.conf /etc/snapsh.conf
cp -v $SRC_DIR/snapsh-post-rollback.service /etc/systemd/system/
systemctl daemon-reload
mount_check
printf "Please enable snapsh-post-rollback.service to automatically remove leftover subvolumes after rollbacks.\n"
printf "(Separate backup snapshots of current subvolumes are created automatically by snapsh)\n"
exit 0
fi
}
# If no options are given, display help
if [[ "$#" -eq 0 ]]; then
help
exit 0
fi
# Options parsing:
OPTIONS=$(getopt -a -n snapsh -o hvs:d:lr:t: --long help,snapshot:,description:,list,remove:,rollback:,type:,post-rollback,mount,umount,install,version -- "$@")
# Invalid options (getopt returns nonzero)
if [[ "$?" -ne 0 ]]; then
printf "Invalid options.\n"
help
exit 1
fi
eval set -- "$OPTIONS"
# Loop through options until -- is reached
while true; do
case "$1" in
-h | --help)
help
exit 0
;;
-d | --description)
DESCRIPTION="$2"
shift 2
;;
-s | --snapshot)
SUBVOLUME="$2"
SNAPSHOT=true
shift 2
;;
-l | --list)
LIST=true
shift
;;
-r | --remove)
REMOVE_TARGETS="$2"
remove
shift 2
;;
--rollback)
ROLLBACK_TARGET="$2"
rollback
shift 2
;;
--post-rollback)
post-rollback
shift
;;
-t | --type)
case "$2" in
manual)
SET_TYPE="manual";;
auto)
SET_TYPE="auto";;
boot)
SET_TYPE="boot";;
backup)
SET_TYPE="backup";;
*)
printf "\nIncorrect TYPE value.\n"
exit 1;;
esac
shift 2
;;
--mount)
SNAPSH_MOUNT=true
mount_check
exit 0
;;
--umount)
exit 0
;;
--install)
install
exit
;;
-v | --version)
version
exit
;;
--)
shift
break
;;
esac
done
if [[ -n "${SNAPSHOT}" ]]; then
snapshot
elif [[ -n "${LIST}" ]]; then
list
fi