399 lines
11 KiB
Bash
Executable File
399 lines
11 KiB
Bash
Executable File
|
|
#!/bin/bash
|
|
|
|
# These can be overridden when running this script
|
|
HOSTNAME=${HOSTNAME:-$(hostname)}
|
|
BACKUP_HOST=${BACKUP_HOST:-backup.jim.sh}
|
|
BACKUP_USER=${BACKUP_USER:-jim-backups}
|
|
BACKUP_REPO=${BACKUP_REPO:-borg/${HOSTNAME}}
|
|
|
|
# Main dir is where this repo was checked out
|
|
BORG_DIR="$(realpath "$(dirname "$0")")"
|
|
cd "${BORG_DIR}"
|
|
|
|
BORG_BIN="${BORG_DIR}/bin/borg.$(uname -m)"
|
|
|
|
# Use stable host ID in case MAC address changes.
|
|
# Note that this host ID is only used to manage locks, so it's
|
|
# not crucial that it remains stable.
|
|
HOSTID="${HOSTNAME}@$(python3 -c 'import uuid;print(uuid.getnode())')"
|
|
|
|
function error_handler() {
|
|
echo "Error at $1 line $2:"
|
|
echo -n '>>> ' ; tail -n +"$2" < "$1" | head -1
|
|
echo "... exited with code $3"
|
|
exit "$3"
|
|
}
|
|
trap 'error_handler ${BASH_SOURCE} ${LINENO} $?' ERR
|
|
set -o errexit
|
|
set -o errtrace
|
|
|
|
# Create pip environment
|
|
setup_venv()
|
|
{
|
|
if ! which pipenv >/dev/null 2>&1 ; then
|
|
echo "pipenv not found, try: sudo apt install pipenv"
|
|
exit 1
|
|
fi
|
|
mkdir -p .venv
|
|
pipenv install
|
|
}
|
|
|
|
# Create shell script with environment variables
|
|
create_borg_vars()
|
|
{
|
|
VARS=${BORG_DIR}/vars.sh
|
|
|
|
# These variables are used elsewhere in this script
|
|
BORG_REPO="ssh://${BACKUP_USER}@${BACKUP_HOST}/./${BACKUP_REPO}"
|
|
BORG=${BORG_DIR}/borg.sh
|
|
SSH=$BORG_DIR/ssh
|
|
|
|
cat >"$VARS" <<EOF
|
|
export BACKUP_USER=${BACKUP_USER}
|
|
export BACKUP_HOST=${BACKUP_HOST}
|
|
export BACKUP_REPO=${BACKUP_REPO}
|
|
export HOSTNAME=${HOSTNAME}
|
|
export BORG_REPO=${BORG_REPO}
|
|
export BORG_HOST_ID=${HOSTID}
|
|
export BORG_PASSCOMMAND="cat ${BORG_DIR}/passphrase"
|
|
export BORG_DIR=${BORG_DIR}
|
|
export SSH=${SSH}
|
|
export BORG=${BORG}
|
|
export BORG_BIN=${BORG_BIN}
|
|
EOF
|
|
if ! "$BORG" -h >/dev/null ; then
|
|
error "Can't run the borg wrapper; does borg work?"
|
|
fi
|
|
}
|
|
|
|
# Update paths in README and backup.py
|
|
update_paths()
|
|
{
|
|
sed -i \
|
|
-e "s!\${HOSTNAME}!${HOSTNAME}!g" \
|
|
-e "s!\${BORG_DIR}!${BORG_DIR}!g" \
|
|
-e "s!\${BORG_BIN}!${BORG_BIN}!g" \
|
|
-e "s!\${BACKUP_USER}!${BACKUP_USER}!g" \
|
|
-e "s!\${BACKUP_HOST}!${BACKUP_HOST}!g" \
|
|
-e "s!\${BACKUP_REPO}!${BACKUP_REPO}!g" \
|
|
README.md
|
|
|
|
sed -i\
|
|
-e "1c#!${BORG_DIR}/.venv/bin/python" \
|
|
backup.py
|
|
}
|
|
|
|
# See if we're just supposed to update an existing install, or recovering
|
|
RECOVER=0
|
|
if [ "$1" == "--recover" ] ; then
|
|
if [ -e "vars.sh" ]; then
|
|
echo "It looks like this borg was already set up, can only recover from fresh start"
|
|
exit 1
|
|
fi
|
|
RECOVER=1
|
|
|
|
elif [ "$1" == "--update-paths" ] || [ "$1" == "--update" ] ; then
|
|
if [ -e "vars.sh" ]; then
|
|
echo "Updating paths and variables"
|
|
update_paths
|
|
setup_venv
|
|
create_borg_vars
|
|
exit 0
|
|
else
|
|
echo "Can't update, not set up yet"
|
|
exit 1
|
|
fi
|
|
|
|
elif [ -e "vars.sh" ]; then
|
|
echo "Error: BORG_DIR $BORG_DIR already looks set up; giving up."
|
|
echo "Use \"git clean\" to return it to original state if desired"
|
|
exit 1
|
|
|
|
fi
|
|
|
|
# Make a temp dir to work in
|
|
TMP=$(mktemp -d)
|
|
|
|
# Install some cleanup handlers
|
|
cleanup()
|
|
{
|
|
set +o errexit
|
|
set +o errtrace
|
|
trap - ERR
|
|
ssh -o ControlPath="$TMP"/ssh-control -O exit x >/dev/null 2>&1
|
|
rm -rf -- "$TMP"
|
|
}
|
|
cleanup_int()
|
|
{
|
|
echo
|
|
cleanup
|
|
exit 1
|
|
}
|
|
trap cleanup 0
|
|
trap cleanup_int 1 2 15
|
|
|
|
msg()
|
|
{
|
|
color="$1"
|
|
shift
|
|
echo -ne "\033[1;${color}m===\033[0;${color}m" "$@"
|
|
echo -e "\033[0m"
|
|
}
|
|
log(){ msg 33 "$@" ; }
|
|
notice() { msg 32 "$@" ; }
|
|
warn() { msg 31 "$@" ; }
|
|
error() { msg 31 "Error:" "$@" ; exit 1 ; }
|
|
|
|
print_random_key()
|
|
{
|
|
dd if=/dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 16
|
|
}
|
|
|
|
generate_keys()
|
|
{
|
|
if [ $RECOVER -eq 1 ] ; then
|
|
echo "Recovering configuration in order to use an existing backup"
|
|
read -s -p "Repo key for \"borg ${HOSTNAME}\": " PASS_REPOKEY
|
|
echo
|
|
read -s -p "Again: " PASS_REPOKEY2
|
|
echo
|
|
if [ -z "$PASS_REPOKEY" ] || [ $PASS_REPOKEY != $PASS_REPOKEY2 ] ; then
|
|
echo "Bad repo key"
|
|
exit 1
|
|
fi
|
|
else
|
|
PASS_REPOKEY=$(print_random_key)
|
|
fi
|
|
echo "$PASS_REPOKEY" > passphrase
|
|
chmod 600 passphrase
|
|
}
|
|
|
|
# Run a command on the remote host over an existing SSH tunnel
|
|
run_ssh_command()
|
|
{
|
|
ssh -o ControlPath="$TMP"/ssh-control use-existing-control-tunnel "$@"
|
|
}
|
|
|
|
# Configure SSH key-based login
|
|
configure_ssh()
|
|
{
|
|
mkdir "$SSH"
|
|
|
|
# Create keys
|
|
log "Creating SSH keys"
|
|
ssh-keygen -N "" -t ecdsa \
|
|
-C "backup-appendonly@$HOSTID" -f "$SSH/id_ecdsa_appendonly"
|
|
ssh-keygen -N "" -t ecdsa \
|
|
-C "backup-notify@$HOSTID" -f "$SSH/id_ecdsa_notify"
|
|
|
|
# Create config snippets
|
|
log "Creating SSH config and wrapper script"
|
|
cat >> "$SSH/config" <<EOF
|
|
User $BACKUP_USER
|
|
ControlPath none
|
|
ServerAliveInterval 120
|
|
Compression no
|
|
UserKnownHostsFile $SSH/known_hosts
|
|
ForwardX11 no
|
|
ForwardAgent no
|
|
BatchMode yes
|
|
IdentitiesOnly yes
|
|
EOF
|
|
|
|
# Connect to backup host, using persistent control socket
|
|
log "Connecting to server"
|
|
log "Please enter password; look in Bitwarden for: ssh ${BACKUP_HOST} / ${BACKUP_USER}"
|
|
ssh -F "$SSH/config" -o BatchMode=no -o PubkeyAuthentication=no \
|
|
-o ControlMaster=yes -o ControlPath="$TMP/ssh-control" \
|
|
-o StrictHostKeyChecking=accept-new \
|
|
-f "${BACKUP_USER}@${BACKUP_HOST}" sleep 600
|
|
if ! run_ssh_command true >/dev/null 2>&1 </dev/null ; then
|
|
error "SSH failed"
|
|
fi
|
|
log "Connected to ${BACKUP_USER}@${BACKUP_HOST}"
|
|
|
|
# Since we now have an SSH connection, check repo existence
|
|
if [ $RECOVER -eq 0 ] && run_ssh_command "test -e $BACKUP_REPO"; then
|
|
error "$BACKUP_REPO already exists on the server, bailing out"
|
|
elif [ $RECOVER -ne 0 ] && ! run_ssh_command "test -e $BACKUP_REPO"; then
|
|
error "$BACKUP_REPO does NOT exist on the server, can't recover backup config"
|
|
fi
|
|
|
|
# Copy SSH keys to the server's authorized_keys file, removing any
|
|
# existing keys with this HOSTID.
|
|
log "Setting up SSH keys on remote host"
|
|
REMOTE_BORG="borg/borg"
|
|
cmd="$REMOTE_BORG serve --restrict-to-repository ~/$BACKUP_REPO"
|
|
|
|
keys=".ssh/authorized_keys"
|
|
backup="${keys}.old-$(date +%Y%m%d-%H%M%S)"
|
|
run_ssh_command "mkdir -p .ssh; chmod 700 .ssh; touch $keys"
|
|
run_ssh_command "mv $keys $backup; sed '/@$HOSTID\$/d' < $backup > $keys"
|
|
run_ssh_command "if cmp -s $backup $keys; then rm $backup ; fi"
|
|
run_ssh_command "cat >> .ssh/authorized_keys" <<EOF
|
|
command="$cmd --append-only",restrict $(cat "$SSH/id_ecdsa_appendonly.pub")
|
|
command="borg/notify.sh",restrict $(cat "$SSH/id_ecdsa_notify.pub")
|
|
EOF
|
|
|
|
# Test that everything worked
|
|
log "Testing SSH login with new key"
|
|
if ! ssh -F "$SSH/config" -i "$SSH/id_ecdsa_appendonly" -T \
|
|
"${BACKUP_USER}@${BACKUP_HOST}" "$REMOTE_BORG" --version </dev/null ; then
|
|
error "Logging in with a key failed -- is server set up correctly?"
|
|
fi
|
|
log "Remote connection OK!"
|
|
}
|
|
|
|
# Create the repository on the server
|
|
create_repo()
|
|
{
|
|
log "Creating repo $BACKUP_REPO"
|
|
# Create repo
|
|
$BORG init --make-parent-dirs --encryption repokey
|
|
}
|
|
|
|
# Export keys as HTML page
|
|
export_keys()
|
|
{
|
|
log "Exporting keys"
|
|
$BORG key export --paper '' key.txt
|
|
chmod 600 key.txt
|
|
cat >>key.txt <<EOF
|
|
|
|
Repository: ${BORG_REPO}
|
|
Passphrase: ${PASS_REPOKEY}
|
|
EOF
|
|
}
|
|
|
|
configure_systemd()
|
|
{
|
|
TIMER=borg-backup.timer
|
|
SERVICE=borg-backup.service
|
|
TIMER_UNIT=${BORG_DIR}/${TIMER}
|
|
SERVICE_UNIT=${BORG_DIR}/${SERVICE}
|
|
|
|
log "Creating systemd files"
|
|
|
|
# Choose a time between 1am and 6am based on this hostname
|
|
HASH=$(echo hash of "$HOSTNAME" | sha1sum)
|
|
HOUR=$((0x${HASH:0:8} % 5 + 1))
|
|
MINUTE=$((0x${HASH:8:8} % 6 * 10))
|
|
TIME=$(printf %02d:%02d:00 $HOUR $MINUTE)
|
|
|
|
log "Backup time is $TIME"
|
|
|
|
cat > "$TIMER_UNIT" <<EOF
|
|
[Unit]
|
|
Description=Borg backup to ${BACKUP_HOST}
|
|
|
|
[Timer]
|
|
OnCalendar=*-*-* $TIME
|
|
Persistent=true
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
EOF
|
|
|
|
cat >> "$SERVICE_UNIT" <<EOF
|
|
[Unit]
|
|
Description=Borg backup to ${BACKUP_HOST}
|
|
|
|
[Service]
|
|
Type=simple
|
|
ExecStart=${BORG_DIR}/backup.py
|
|
Nice=10
|
|
IOSchedulingClass=best-effort
|
|
IOSchedulingPriority=6
|
|
Restart=on-failure
|
|
RestartSec=600
|
|
EOF
|
|
|
|
if [ $RECOVER -eq 1 ] ; then
|
|
log "Partially setting up systemd"
|
|
ln -sfv "${TIMER_UNIT}" /etc/systemd/system
|
|
ln -sfv "${SERVICE_UNIT}" /etc/systemd/system
|
|
systemctl --no-ask-password daemon-reload
|
|
systemctl --no-ask-password stop ${TIMER}
|
|
systemctl --no-ask-password disable ${TIMER}
|
|
warn "Since we're recovering, systemd automatic backups aren't enabled"
|
|
warn "Do something like this to configure automatic backups:"
|
|
echo " sudo systemctl enable ${TIMER} &&"
|
|
echo " sudo systemctl start ${TIMER}"
|
|
warn ""
|
|
else
|
|
log "Setting up systemd"
|
|
if (
|
|
ln -sfv "${TIMER_UNIT}" /etc/systemd/system &&
|
|
ln -sfv "${SERVICE_UNIT}" /etc/systemd/system &&
|
|
systemctl --no-ask-password daemon-reload &&
|
|
systemctl --no-ask-password enable ${TIMER} &&
|
|
systemctl --no-ask-password start ${TIMER}
|
|
); then
|
|
log "Backup timer installed:"
|
|
systemctl list-timers --no-pager ${TIMER}
|
|
else
|
|
warn ""
|
|
warn "Systemd setup failed"
|
|
warn "Do something like this to configure automatic backups:"
|
|
echo " sudo ln -sfv \"${TIMER_UNIT}\" /etc/systemd/system &&"
|
|
echo " sudo ln -sfv \"${SERVICE_UNIT}\" /etc/systemd/system &&"
|
|
echo " sudo systemctl daemon-reload &&"
|
|
echo " sudo systemctl enable ${TIMER} &&"
|
|
echo " sudo systemctl start ${TIMER}"
|
|
warn ""
|
|
fi
|
|
fi
|
|
}
|
|
|
|
git_setup()
|
|
{
|
|
if ! git checkout -b "setup-${HOSTNAME}" ; then
|
|
warn "Git setup failed; ignoring"
|
|
return
|
|
fi
|
|
|
|
log "Committing local changes to git"
|
|
git add README.md borg-backup.service borg-backup.timer vars.sh
|
|
git commit -a -m "autocommit after initial setup on ${HOSTNAME}"
|
|
}
|
|
|
|
log "Configuration:"
|
|
log " Backup server host: ${BACKUP_HOST}"
|
|
log " Backup server user: ${BACKUP_USER}"
|
|
log " Repository path: ${BACKUP_REPO}"
|
|
|
|
setup_venv
|
|
create_borg_vars
|
|
generate_keys
|
|
configure_ssh
|
|
[ $RECOVER -eq 0 ] && create_repo
|
|
export_keys
|
|
configure_systemd
|
|
update_paths
|
|
git_setup
|
|
|
|
echo
|
|
if [ $RECOVER -eq 1 ] ; then
|
|
notice "You should be set up with borg pointing to the existing repo now."
|
|
notice "Use commands like these to look at the backup:"
|
|
notice " sudo /opt/borg/borg.sh info"
|
|
notice " sudo /opt/borg/borg.sh list"
|
|
notice "You'll want to now restore files like ${BORG_DIR}/config.yaml before enabling systemd timers"
|
|
else
|
|
notice "Add this password to Bitwarden:"
|
|
notice ""
|
|
notice " Name: borg ${HOSTNAME}"
|
|
notice " Username: repo key"
|
|
notice " Password: $PASS_REPOKEY"
|
|
notice ""
|
|
notice "Test the backup file list with"
|
|
notice " sudo ${BORG_DIR}/backup.py --dry-run"
|
|
notice "and make any necessary adjustments to:"
|
|
notice " ${BORG_DIR}/config.yaml"
|
|
fi
|
|
|
|
echo
|
|
|
|
echo "All done"
|