My backup scripts and tools
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

341 lines
8.7 KiB

  1. #!/bin/bash
  2. # These can be overridden when running this script
  3. HOSTNAME=${HOSTNAME:-$(hostname)}
  4. BACKUP_HOST=${BACKUP_HOST:-backup.jim.sh}
  5. BACKUP_USER=${BACKUP_USER:-jim-backups}
  6. BACKUP_REPO=${BACKUP_REPO:-borg/${HOSTNAME}}
  7. # Main dir is where this repo was checked out
  8. BORG_DIR="$(realpath "$(dirname "$0")")"
  9. cd "${BORG_DIR}"
  10. # This is named with uppercase so that it doesn't tab-complete for
  11. # "./b<tab>", which should give us "./borg.sh"
  12. BORG_BIN="${BORG_DIR}/Borg.bin"
  13. # Use stable host ID in case MAC address changes
  14. HOSTID="${HOSTNAME}@$(python -c 'import uuid;print(uuid.getnode())')"
  15. function error_handler() {
  16. echo "Error at $1 line $2:"
  17. echo -n '>>> ' ; tail -n +"$2" < "$1" | head -1
  18. echo "... exited with code $3"
  19. exit "$3"
  20. }
  21. trap 'error_handler ${BASH_SOURCE} ${LINENO} $?' ERR
  22. set -o errexit
  23. set -o errtrace
  24. if [ -e ".setup-complete" ]; then
  25. echo "Error: BORG_DIR $BORG_DIR was already set up; giving up."
  26. echo "Use \"git clean\" to return it to original state if desired"
  27. exit 1
  28. fi
  29. # Make a temp dir to work in
  30. TMP=$(mktemp -d)
  31. # Install some cleanup handlers
  32. cleanup()
  33. {
  34. set +o errexit
  35. set +o errtrace
  36. trap - ERR
  37. ssh -o ControlPath="$TMP"/ssh-control -O exit x >/dev/null 2>&1
  38. rm -rf -- "$TMP"
  39. }
  40. cleanup_int()
  41. {
  42. echo
  43. cleanup
  44. exit 1
  45. }
  46. trap cleanup 0
  47. trap cleanup_int 1 2 15
  48. msg()
  49. {
  50. color="$1"
  51. shift
  52. echo -ne "\033[1;${color}m===\033[0;${color}m" "$@"
  53. echo -e "\033[0m"
  54. }
  55. log(){ msg 33 "$@" ; }
  56. notice() { msg 32 "$@" ; }
  57. warn() { msg 31 "$@" ; }
  58. error() { msg 31 "Error:" "$@" ; exit 1 ; }
  59. # Create pip environment
  60. setup_venv()
  61. {
  62. mkdir .venv
  63. pipenv install
  64. }
  65. # Create shell script with environment variables
  66. create_borg_vars()
  67. {
  68. VARS=${BORG_DIR}/vars.sh
  69. # These variables are used elsewhere in this script
  70. BORG_REPO="ssh://${BACKUP_USER}@${BACKUP_HOST}/./${BACKUP_REPO}"
  71. BORG=${BORG_DIR}/borg.sh
  72. SSH=$BORG_DIR/ssh
  73. cat >"$VARS" <<EOF
  74. export BACKUP_USER=${BACKUP_USER}
  75. export BACKUP_HOST=${BACKUP_HOST}
  76. export BACKUP_REPO=${BACKUP_REPO}
  77. export HOSTNAME=${HOSTNAME}
  78. export BORG_REPO=${BORG_REPO}
  79. export BORG_HOST_ID=${HOSTID}
  80. export BORG_PASSCOMMAND="cat ${BORG_DIR}/passphrase"
  81. export BORG_DIR=${BORG_DIR}
  82. export SSH=${SSH}
  83. export BORG=${BORG}
  84. export BORG_BIN=${BORG_BIN}
  85. EOF
  86. if ! "$BORG" -h >/dev/null ; then
  87. error "Can't run the borg wrapper; does borg work?"
  88. fi
  89. }
  90. print_random_key()
  91. {
  92. dd if=/dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 16
  93. }
  94. generate_keys()
  95. {
  96. PASS_SSH=$(print_random_key)
  97. PASS_REPOKEY=$(print_random_key)
  98. echo "$PASS_REPOKEY" > passphrase
  99. chmod 600 passphrase
  100. }
  101. # Run a command on the remote host over an existing SSH tunnel
  102. run_ssh_command()
  103. {
  104. ssh -o ControlPath="$TMP"/ssh-control use-existing-control-tunnel "$@"
  105. }
  106. # Configure SSH key-based login
  107. configure_ssh()
  108. {
  109. mkdir "$SSH"
  110. # Create keys
  111. log "Creating SSH keys"
  112. ssh-keygen -N "" -t ecdsa \
  113. -C "backup-appendonly@$HOSTID" -f "$SSH/id_ecdsa_appendonly"
  114. ssh-keygen -N "" -t ecdsa \
  115. -C "backup-notify@$HOSTID" -f "$SSH/id_ecdsa_notify"
  116. ssh-keygen -N "$PASS_SSH" -t ecdsa \
  117. -C "backup@$HOSTID" -f "$SSH/id_ecdsa"
  118. # Create config snippets
  119. log "Creating SSH config and wrapper script"
  120. cat >> "$SSH/config" <<EOF
  121. User $BACKUP_USER
  122. ControlPath none
  123. ServerAliveInterval 120
  124. Compression no
  125. UserKnownHostsFile $SSH/known_hosts
  126. ForwardX11 no
  127. ForwardAgent no
  128. BatchMode yes
  129. IdentitiesOnly yes
  130. EOF
  131. # Connect to backup host, using persistent control socket
  132. log "Connecting to server"
  133. log "Please enter password; look in Bitwarden for: ssh ${BACKUP_HOST} / ${BACKUP_USER}"
  134. ssh -F "$SSH/config" -o BatchMode=no -o PubkeyAuthentication=no \
  135. -o ControlMaster=yes -o ControlPath="$TMP/ssh-control" \
  136. -o StrictHostKeyChecking=accept-new \
  137. -f "${BACKUP_USER}@${BACKUP_HOST}" sleep 600
  138. if ! run_ssh_command true >/dev/null 2>&1 </dev/null ; then
  139. error "SSH failed"
  140. fi
  141. log "Connected to ${BACKUP_USER}@${BACKUP_HOST}"
  142. # Since we now have an SSH connection, check that the repo doesn't exist
  143. if run_ssh_command "test -e $BACKUP_REPO" ; then
  144. error "$BACKUP_REPO already exists on the server, bailing out"
  145. fi
  146. # Copy SSH keys to the server's authorized_keys file, removing any
  147. # existing keys with this HOSTID.
  148. log "Setting up SSH keys on remote host"
  149. REMOTE_BORG="borg/borg"
  150. cmd="$REMOTE_BORG serve --restrict-to-repository ~/$BACKUP_REPO"
  151. keys=".ssh/authorized_keys"
  152. backup="${keys}.old-$(date +%Y%m%d-%H%M%S)"
  153. run_ssh_command "mkdir -p .ssh; chmod 700 .ssh; touch $keys"
  154. run_ssh_command "mv $keys $backup; sed '/@$HOSTID\$/d' < $backup > $keys"
  155. run_ssh_command "if cmp -s $backup $keys; then rm $backup ; fi"
  156. run_ssh_command "cat >> .ssh/authorized_keys" <<EOF
  157. command="$cmd --append-only",restrict $(cat "$SSH/id_ecdsa_appendonly.pub")
  158. command="borg/notify.sh",restrict $(cat "$SSH/id_ecdsa_notify.pub")
  159. command="$cmd",restrict $(cat "$SSH/id_ecdsa.pub")
  160. EOF
  161. # Test that everything worked
  162. log "Testing SSH login with new key"
  163. if ! ssh -F "$SSH/config" -i "$SSH/id_ecdsa_appendonly" -T \
  164. "${BACKUP_USER}@${BACKUP_HOST}" "$REMOTE_BORG" --version </dev/null ; then
  165. error "Logging in with a key failed -- is server set up correctly?"
  166. fi
  167. log "Remote connection OK!"
  168. }
  169. # Create the repository on the server
  170. create_repo()
  171. {
  172. log "Creating repo $BACKUP_REPO"
  173. # Create repo
  174. $BORG init --make-parent-dirs --encryption repokey
  175. }
  176. # Export keys as HTML page
  177. export_keys()
  178. {
  179. log "Exporting keys"
  180. $BORG key export --paper '' key.txt
  181. chmod 600 key.txt
  182. cat >>key.txt <<EOF
  183. Repository: ${BORG_REPO}
  184. Passphrase: ${PASS_REPOKEY}
  185. EOF
  186. }
  187. configure_systemd()
  188. {
  189. TIMER=borg-backup.timer
  190. SERVICE=borg-backup.service
  191. TIMER_UNIT=${BORG_DIR}/${TIMER}
  192. SERVICE_UNIT=${BORG_DIR}/${SERVICE}
  193. log "Creating systemd files"
  194. # Choose a time between 1am and 6am based on this hostname
  195. HASH=$(echo hash of "$HOSTNAME" | sha1sum)
  196. HOUR=$((0x${HASH:0:8} % 5 + 1))
  197. MINUTE=$((0x${HASH:8:8} % 6 * 10))
  198. TIME=$(printf %02d:%02d:00 $HOUR $MINUTE)
  199. log "Backup time is $TIME"
  200. cat > "$TIMER_UNIT" <<EOF
  201. [Unit]
  202. Description=Borg backup to ${BACKUP_HOST}
  203. [Timer]
  204. OnCalendar=*-*-* $TIME
  205. Persistent=true
  206. [Install]
  207. WantedBy=timers.target
  208. EOF
  209. cat >> "$SERVICE_UNIT" <<EOF
  210. [Unit]
  211. Description=Borg backup to ${BACKUP_HOST}
  212. [Service]
  213. Type=simple
  214. ExecStart=${BORG_DIR}/backup.py
  215. Nice=10
  216. IOSchedulingClass=best-effort
  217. IOSchedulingPriority=6
  218. EOF
  219. log "Setting up systemd"
  220. if (
  221. ln -sfv "${TIMER_UNIT}" /etc/systemd/system &&
  222. ln -sfv "${SERVICE_UNIT}" /etc/systemd/system &&
  223. systemctl --no-ask-password daemon-reload &&
  224. systemctl --no-ask-password enable ${TIMER} &&
  225. systemctl --no-ask-password start ${TIMER}
  226. ); then
  227. log "Backup timer installed:"
  228. systemctl list-timers ${TIMER}
  229. else
  230. warn ""
  231. warn "Systemd setup failed"
  232. warn "Do something like this to configure automatic backups:"
  233. echo " sudo ln -sfv \"${TIMER_UNIT}\" /etc/systemd/system &&"
  234. echo " sudo ln -sfv \"${SERVICE_UNIT}\" /etc/systemd/system &&"
  235. echo " sudo systemctl daemon-reload &&"
  236. echo " sudo systemctl enable ${TIMER} &&"
  237. echo " sudo systemctl start ${TIMER}"
  238. warn ""
  239. fi
  240. }
  241. update_paths()
  242. {
  243. sed -i \
  244. -e "s!\${HOSTNAME}!${HOSTNAME}!g" \
  245. -e "s!\${BORG_DIR}!${BORG_DIR}!g" \
  246. -e "s!\${BACKUP_USER}!${BACKUP_USER}!g" \
  247. -e "s!\${BACKUP_HOST}!${BACKUP_HOST}!g" \
  248. -e "s!\${BACKUP_REPO}!${BACKUP_REPO}!g" \
  249. README.md
  250. sed -i\
  251. -e "1c#!${BORG_DIR}/.venv/bin/python" \
  252. backup.py
  253. }
  254. git_setup()
  255. {
  256. if ! git checkout -b "setup-${HOSTNAME}" ; then
  257. warn "Git setup failed; ignoring"
  258. return
  259. fi
  260. log "Committing local changes to git"
  261. git add README.md borg-backup.service borg-backup.timer vars.sh
  262. git commit -a -m "autocommit after initial setup on ${HOSTNAME}"
  263. }
  264. log "Configuration:"
  265. log " Backup server host: ${BACKUP_HOST}"
  266. log " Backup server user: ${BACKUP_USER}"
  267. log " Repository path: ${BACKUP_REPO}"
  268. setup_venv
  269. create_borg_vars
  270. generate_keys
  271. configure_ssh
  272. create_repo
  273. export_keys
  274. configure_systemd
  275. update_paths
  276. git_setup
  277. echo
  278. notice "Add these two passwords to Bitwarden:"
  279. notice ""
  280. notice " Name: borg ${HOSTNAME}"
  281. notice " Username: read-write ssh key"
  282. notice " Password: $PASS_SSH"
  283. notice ""
  284. notice " Name: borg ${HOSTNAME}"
  285. notice " Username: repo key"
  286. notice " Password: $PASS_REPOKEY"
  287. notice ""
  288. notice "Test the backup file list with"
  289. notice " sudo ${BORG_DIR}/backup.py --dry-run"
  290. notice "and make any necessary adjustments to:"
  291. notice " ${BORG_DIR}/config.yaml"
  292. echo
  293. echo "All done"