#!/bin/bash # These can be overridden when running this script 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}" # This is named with uppercase so that it doesn't tab-complete for # "./b<tab>", which should give us "./borg.sh" BORG_BIN="${BORG_DIR}/Borg.bin" # Use stable host ID in case MAC address changes HOSTID="$(hostname -f)@$(python -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 if [ -e ".setup-complete" ]; then echo "Error: BORG_DIR $BORG_DIR was already 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 ; } # Create pip environment setup_venv() { mkdir .venv pipenv install } # Create wrapper to execute borg create_borg_wrapper() { BORG=${BORG_DIR}/borg.sh BORG_REPO="ssh://${BACKUP_USER}@${BACKUP_HOST}/./${BACKUP_REPO}" SSH=$BORG_DIR/ssh cat >"$BORG" <<EOF #!/bin/sh export BORG_REPO=${BORG_REPO} export BORG_PASSCOMMAND="cat ${BORG_DIR}/passphrase" export BORG_HOST_ID=${HOSTID} export BORG_BASE_DIR=${BORG_DIR} export BORG_CACHE_DIR=${BORG_DIR}/cache export BORG_CONFIG_DIR=${BORG_DIR}/config if [ "\$1" = "--rw" ] ; then if [ "$BORG_RW_KEY_ADDED" != "1" ] ; then echo "=== Need SSH key passphrase. Check Bitwarden for:" echo "=== borg $(hostname) / read-write SSH key" fi export BORG_RSH="ssh -F $SSH/config -o BatchMode=no -i $SSH/id_ecdsa" shift else export BORG_RSH="ssh -F $SSH/config -i $SSH/id_ecdsa_appendonly" fi exec "${BORG_BIN}" "\$@" EOF chmod +x "$BORG" if ! "$BORG" -h >/dev/null ; then error "Can't run the new borg wrapper; does borg work?" fi } print_random_key() { dd if=/dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 16 } generate_keys() { PASS_SSH=$(print_random_key) PASS_REPOKEY=$(print_random_key) 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 "$PASS_SSH" -t ecdsa \ -C "backup@$HOSTID" -f "$SSH/id_ecdsa" # 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: ${BACKUP_USER}@${BACKUP_HOST}" 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 that the repo doesn't exist if run_ssh_command "test -e $BACKUP_REPO" ; then error "$BACKUP_REPO already exists on the server, bailing out" 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" cmd="borg/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_appendonly.pub") command="$cmd",restrict $(cat "$SSH/id_ecdsa.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}" 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" cat > "$TIMER_UNIT" <<EOF [Unit] Description=Borg backup to ${BACKUP_HOST} [Timer] OnCalendar=*-*-* 01:00:00 RandomizedDelaySec=1800 FixedRandomDelay=true 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 EOF 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 ${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 } update_paths() { sed -i \ -e "s!\${HOSTNAME}!$(hostname)!g" \ -e "s!\${BORG_DIR}!${BORG_DIR}!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 } 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 borg.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_wrapper generate_keys configure_ssh create_repo export_keys configure_systemd update_paths git_setup echo notice "Add these two passwords to Bitwarden:" notice "" notice " Name: borg $(hostname)" notice " Username: read-write ssh key" notice " Password: $PASS_SSH" 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" echo echo "All done"