11#! /usr/bin/env bash
22
33# =================================================================
4- # Restic Backup Script v0.39 - 2025.10.25
4+ # Restic Backup Script v0.40 - 2025.11.18
55# =================================================================
66
77export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
88set -euo pipefail
99umask 077
1010
1111# --- Script Constants ---
12- SCRIPT_VERSION=" 0.39 "
12+ SCRIPT_VERSION=" 0.40 "
1313SCRIPT_DIR=$( cd -- " $( dirname -- " ${BASH_SOURCE[0]} " ) " & > /dev/null && pwd)
1414PROG_NAME=$( basename " $0 " ) ; readonly PROG_NAME
1515CONFIG_FILE=" ${SCRIPT_DIR} /restic-backup.conf"
4747# =================================================================
4848
4949import_restic_key () {
50- local fpr=" CF8F18F2844575973F79D4E191A6868BD3F7A907"
51- # Check local user keyring
50+ local fpr servers debian_keyring
51+
52+ # Official Fingerprint
53+ fpr=" CF8F18F2844575973F79D4E191A6868BD3F7A907"
54+
55+ # 1. Check local user keyring first
5256 if gpg --list-keys " $fpr " > /dev/null 2>&1 ; then
5357 return 0
5458 fi
55- # Check Debian/Ubuntu system keyring
56- local debian_keyring=" /usr/share/keyrings/restic-archive-keyring.gpg"
57- if [[ -f " $debian_keyring " ]]; then
58- echo " Found debian keyring, checking for key..."
59- if gpg --no-default-keyring --keyring " $debian_keyring " --list-keys " $fpr " > /dev/null 2>&1 ; then
60- echo " Importing trusted key from system keyring..."
61- gpg --no-default-keyring --keyring " $debian_keyring " --export " $fpr " | gpg --import > /dev/null 2>&1
62- return $?
63- fi
59+
60+ echo " Restic PGP key not found. Attempting import..."
61+
62+ # 2. Attempt Direct Download from Restic.net
63+ echo " Attempting direct download from restic.net..."
64+ if curl -sL " https://restic.net/gpg-key-alex.asc" | gpg --import > /dev/null 2>&1 ; then
65+ echo " Key imported successfully via direct download."
66+ return 0
6467 fi
65- # Try public keyservers fallback
66- local servers=( " hkps://keys.openpgp.org" " hkps://keyserver.ubuntu.com" )
68+
69+ # 3. Try Keyservers
70+ servers=( " hkps://keyserver.ubuntu.com" " hkps://keys.openpgp.org" " hkps://pgp.mit.edu" )
6771 for server in " ${servers[@]} " ; do
6872 echo " Attempting to fetch from $server ..."
69- if gpg --keyserver " $server " --recv-keys " $fpr " ; then
70- echo " Key imported successfully."
73+ if gpg --keyserver " $server " --recv-keys " $fpr " > /dev/null 2>&1 ; then
74+ echo " Key imported successfully from $server ."
7175 return 0
7276 fi
7377 done
74- echo " Failed to import restic PGP key." >&2
78+
79+ # 4. Check Debian/Ubuntu system keyring (Fallback for apt-installed systems)
80+ debian_keyring=" /usr/share/keyrings/restic-archive-keyring.gpg"
81+ if [[ -f " $debian_keyring " ]]; then
82+ echo " Checking system keyring..."
83+ if gpg --no-default-keyring --keyring " $debian_keyring " --export " $fpr " | gpg --import > /dev/null 2>&1 ; then
84+ echo " Imported from system keyring."
85+ return 0
86+ fi
87+ fi
88+
89+ echo -e " ${C_RED} Failed to import restic PGP key from all sources.${C_RESET} " >&2
7590 return 1
7691}
7792
@@ -258,7 +273,7 @@ if [ ! -f "$CONFIG_FILE" ]; then
258273 echo -e " ${C_RED} ERROR: Configuration file not found: $CONFIG_FILE ${C_RESET} " >&2
259274 exit 1
260275fi
261- # shellcheck source=restic-backup.conf
276+ # shellcheck source=/dev/null
262277source " $CONFIG_FILE "
263278REQUIRED_VARS=(
264279 " RESTIC_REPOSITORY"
@@ -326,7 +341,7 @@ display_help() {
326341 echo -e " ${C_BOLD}${C_YELLOW} DEPENDENCIES:${C_RESET} "
327342 echo -e " This script requires: ${C_GREEN} restic, curl, gpg, bzip2, less, jq, flock${C_RESET} "
328343 echo
329- echo -e " Config: ${C_DIM}${CONFIG_FILE}${C_RESET} Log: ${C_DIM}${LOG_FILE}${C_RESET} "
344+ echo -e " Config: ${C_DIM}${CONFIG_FILE}${C_RESET} Log: ${C_DIM}${LOG_FILE:- " (not set) " }${C_RESET} "
330345 echo
331346 echo -e " For full details, see the online documentation: \e]8;;${readme_url} \a${C_CYAN} README.md${C_RESET} \e]8;;\a"
332347 echo -e " ${C_YELLOW} Note:${C_RESET} For restic official documentation See: https://restic.readthedocs.io/"
@@ -354,7 +369,9 @@ handle_crash() {
354369
355370build_backup_command () {
356371 local cmd=(restic)
357- cmd+=($( get_verbosity_flags) )
372+ local -a v_flags
373+ read -ra v_flags <<< " $(get_verbosity_flags)"
374+ cmd+=(" ${v_flags[@]} " )
358375 if [ -n " ${SFTP_CONNECTIONS:- } " ]; then
359376 cmd+=(-o " sftp.connections=${SFTP_CONNECTIONS} " )
360377 fi
@@ -462,7 +479,7 @@ run_unlock() {
462479 echo -e " ${C_YELLOW} Found stale locks in the repository:${C_RESET} "
463480 echo " $lock_info "
464481 local other_processes
465- other_processes=$( ps aux | grep ' restic ' | grep -v ' grep ' || true)
482+ other_processes=$( pgrep -ax restic || true)
466483 if [ -n " $other_processes " ]; then
467484 echo -e " ${C_YELLOW} WARNING: Another restic process appears to be running:${C_RESET} "
468485 echo " $other_processes "
@@ -563,9 +580,11 @@ send_ntfy() {
563580 if [[ " ${NTFY_ENABLED:- false} " != " true" ]] || [ -z " ${NTFY_TOKEN:- } " ] || [ -z " ${NTFY_URL:- } " ]; then
564581 return 0
565582 fi
583+ local safe_title
584+ safe_title=$( echo " $title " | jq -R -r ' sub("\n"; " "; "g")' )
566585 curl -s --max-time 15 \
567586 -u " :$NTFY_TOKEN " \
568- -H " Title: $title " \
587+ -H " Title: $safe_title " \
569588 -H " Tags: $tags " \
570589 -H " Priority: $priority " \
571590 -d " $message " \
@@ -586,12 +605,13 @@ send_discord() {
586605 failure) color=15158332 ;;
587606 * ) color=9807270 ;;
588607 esac
589- local escaped_title escaped_message
590- escaped_title=$( echo " $title " | sed ' s/\\/\\\\/g' | sed ' s/"/\\"/g' )
591- escaped_message=$( echo " $message " | sed ' s/\\/\\\\/g' | sed ' s/"/\\"/g' | sed ' :a;N;$!ba;s/\n/\\n/g' )
592608 local json_payload
593- printf -v json_payload ' {"embeds": [{"title": "%s", "description": "%s", "color": %d, "timestamp": "%s"}]}' \
594- " $escaped_title " " $escaped_message " " $color " " $( date -u +%Y-%m-%dT%H:%M:%S.000Z) "
609+ json_payload=$( jq -n \
610+ --arg title " $title " \
611+ --arg desc " $message " \
612+ --argjson color " $color " \
613+ --arg ts " $( date -u +%Y-%m-%dT%H:%M:%S.000Z) " \
614+ ' {embeds: [{title: $title, description: $desc, color: $color, timestamp: $ts}]}' )
595615 curl -s --max-time 15 \
596616 -H " Content-Type: application/json" \
597617 -d " $json_payload " \
@@ -602,6 +622,7 @@ send_teams() {
602622 local title=" $1 "
603623 local status=" $2 "
604624 local message=" $3 "
625+
605626 if [[ " ${TEAMS_ENABLED:- false} " != " true" ]] || [ -z " ${TEAMS_WEBHOOK_URL:- } " ]; then
606627 return 0
607628 fi
@@ -612,39 +633,39 @@ send_teams() {
612633 failure) color=" attention" ;;
613634 * ) color=" default" ;;
614635 esac
615- local escaped_title
616- escaped_title=$( echo " $title " | sed ' s/\\/\\\\/g' | sed ' s/"/\\"/g' )
617- local escaped_message
618- escaped_message=$( echo " $message " | sed ' s/\\/\\\\/g' | sed ' s/"/\\"/g' | sed ' :a;N;$!ba;s/\n/\\n/g' )
619636 local json_payload
620- printf -v json_payload ' {
621- "type": "message",
622- "attachments": [{
623- "contentType": "application/vnd.microsoft.card.adaptive",
624- "content": {
625- "type": "AdaptiveCard",
626- "version": "1.4",
627- "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
628- "body": [
629- {
630- "type": "TextBlock",
631- "text": "%s",
632- "weight": "bolder",
633- "size": "large",
634- "wrap": true,
635- "color": "%s"
636- },
637- {
638- "type": "TextBlock",
639- "text": "%s",
640- "wrap": true,
641- "separator": true
642- }
643- ],
644- "msteams": { "width": "full", "entities": [] }
645- }
646- }]
647- }' " $escaped_title " " $color " " $escaped_message "
637+ json_payload=$( jq -n \
638+ --arg title " $title " \
639+ --arg msg " $message " \
640+ --arg color " $color " \
641+ ' {
642+ type: "message",
643+ attachments: [{
644+ contentType: "application/vnd.microsoft.card.adaptive",
645+ content: {
646+ type: "AdaptiveCard",
647+ version: "1.4",
648+ "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
649+ body: [
650+ {
651+ type: "TextBlock",
652+ text: $title,
653+ weight: "bolder",
654+ size: "large",
655+ wrap: true,
656+ color: $color
657+ },
658+ {
659+ type: "TextBlock",
660+ text: $msg,
661+ wrap: true,
662+ separator: true
663+ }
664+ ],
665+ msteams: { width: "full", entities: [] }
666+ }
667+ }]
668+ }' )
648669 curl -s --max-time 15 \
649670 -H " Content-Type: application/json" \
650671 -d " $json_payload " \
@@ -665,33 +686,35 @@ send_slack() {
665686 failure) color=" #d50200" ;;
666687 * ) color=" #808080" ;;
667688 esac
668- local escaped_title escaped_message
669- escaped_title=$( echo " $title " | sed ' s/\\/\\\\/g' | sed ' s/"/\\"/g' )
670- escaped_message=$( echo " $message " | sed ' s/\\/\\\\/g' | sed ' s/"/\\"/g' | sed ' :a;N;$!ba;s/\n/\\n/g' )
671689 local json_payload
672- printf -v json_payload ' {
673- "attachments": [
674- {
675- "color": "%s",
676- "blocks": [
677- {
678- "type": "header",
679- "text": {
680- "type": "plain_text",
681- "text": "%s"
682- }
683- },
684- {
685- "type": "section",
686- "text": {
687- "type": "mrkdwn",
688- "text": "%s"
690+ json_payload=$( jq -n \
691+ --arg title " $title " \
692+ --arg msg " $message " \
693+ --arg color " $color " \
694+ ' {
695+ attachments: [
696+ {
697+ color: $color,
698+ blocks: [
699+ {
700+ type: "header",
701+ text: {
702+ type: "plain_text",
703+ text: $title,
704+ emoji: true
705+ }
706+ },
707+ {
708+ type: "section",
709+ text: {
710+ type: "mrkdwn",
711+ text: $msg
712+ }
713+ }
714+ ]
689715 }
690- }
691- ]
692- }
693- ]
694- }' " $color " " $escaped_title " " $escaped_message "
716+ ]
717+ }' )
695718 curl -s --max-time 15 \
696719 -H " Content-Type: application/json" \
697720 -d " $json_payload " \
@@ -876,8 +899,9 @@ run_preflight_checks() {
876899 fi
877900 for source in " ${BACKUP_SOURCES[@]} " ; do
878901 if [[ " $verbosity " == " verbose" ]]; then printf " %-65s" " Source directory ('$source ')..." ; fi
879- if [ ! -d " $source " ] || [ ! -r " $source " ]; then
880- handle_failure " Source directory not found or not readable: $source " " 13"
902+ # Changed -d (directory) to -e (exists) to allow single file backups (v0.40)
903+ if [ ! -e " $source " ] || [ ! -r " $source " ]; then
904+ handle_failure " Source path not found or not readable: $source " " 13"
881905 fi
882906 if [[ " $verbosity " == " verbose" ]]; then echo -e " [${C_GREEN} OK ${C_RESET} ]" ; fi
883907 done
@@ -1251,7 +1275,7 @@ run_backup() {
12511275 files_changed=$( grep " Files:" " $backup_log " | tail -1 | awk ' {print $4}' )
12521276 files_unmodified=$( grep " Files:" " $backup_log " | tail -1 | awk ' {print $6}' )
12531277 data_added=$( grep " Added to the repository:" " $backup_log " | tail -1 | awk ' {print $5" "$6}' )
1254- data_processed=$( grep " processed" " $backup_log " | tail -1 | awk ' {print $1 " "$2} ' )
1278+ data_processed=$( grep " processed" " $backup_log " | tail -1 | awk ' {print $2 " "$3} ' | tr -d ' , ' )
12551279 fi
12561280 cat " $backup_log " >> " $LOG_FILE "
12571281 rm -f " $backup_log "
@@ -1261,10 +1285,11 @@ run_backup() {
12611285 log_message " Backup completed successfully"
12621286 echo -e " ${C_GREEN} ✅ Backup completed${C_RESET} "
12631287 local stats_msg
1264- printf -v stats_msg " Files: %s new, %s changed, %s unmodified\nData added: %s\nDuration: %dm %ds" \
1288+ printf -v stats_msg " Files: %s new, %s changed, %s unmodified\nProcessed: %s\ nData added: %s\nDuration: %dm %ds" \
12651289 " ${files_new:- 0} " \
12661290 " ${files_changed:- 0} " \
12671291 " ${files_unmodified:- 0} " \
1292+ " ${data_processed:- 0} " \
12681293 " ${data_added:- Not applicable} " \
12691294 " $(( duration / 60 )) " \
12701295 " $(( duration % 60 )) "
@@ -1283,7 +1308,9 @@ run_forget() {
12831308 echo -e " ${C_BOLD} --- Cleaning Old Snapshots ---${C_RESET} "
12841309 log_message " Running retention policy"
12851310 local forget_cmd=(restic)
1286- forget_cmd+=($( get_verbosity_flags) )
1311+ local -a v_flags
1312+ read -ra v_flags <<< " $(get_verbosity_flags)"
1313+ forget_cmd+=(" ${v_flags[@]} " )
12871314 forget_cmd+=(forget)
12881315 [ -n " ${KEEP_LAST:- } " ] && forget_cmd+=(--keep-last " $KEEP_LAST " )
12891316 [ -n " ${KEEP_DAILY:- } " ] && forget_cmd+=(--keep-daily " $KEEP_DAILY " )
@@ -1459,7 +1486,9 @@ _run_restore_command() {
14591486 shift 2
14601487 mkdir -p " $restore_dest "
14611488 local restic_cmd=(restic)
1462- restic_cmd+=($( get_verbosity_flags) )
1489+ local -a v_flags
1490+ read -ra v_flags <<< " $(get_verbosity_flags)"
1491+ restic_cmd+=(" ${v_flags[@]} " )
14631492 restic_cmd+=(restore " $snapshot_id " --target " $restore_dest " )
14641493 if [ $# -gt 0 ]; then
14651494 for path in " $@ " ; do
@@ -1642,7 +1671,7 @@ echo "To restore a specific directory from the latest snapshot:"
16421671# restic restore latest --target /mnt/restore --include "/home/user_files"
16431672
16441673EOF
1645- chmod 400 " $tmpfile "
1674+ chmod 400 " $tmpfile "
16461675 mv -f " $tmpfile " " $recovery_file "
16471676 echo -e " \n${C_GREEN} ✅ Recovery Kit generated: ${C_BOLD}${recovery_file}${C_RESET} "
16481677 echo -e " ${C_BOLD}${C_RED} WARNING: This file contains your repository password.${C_RESET} "
0 commit comments