#!/bin/bash # # Script Name: openwrtupload.sh # Description: # Backs up OpenWrt configuration archives to a Backblaze B2 repository # using Restic. Each .gz archive found in the source directory is # extracted to a staging directory, backed up with Restic, and then # removed along with the staging directory. # # Usage: # openwrtupload.sh # # Author: Charlie Silverman # Version: 20261031-01 # # Requirements: # restic, jq, awk, tar, numfmt (optional, for human-readable sizes). # Restic environment file: /root/.restic-env # # Versioning: # Versions use the format YYYYMMDD-xx, where YYYYMMDD is the release date # and xx is the release number for that day, starting at 01. # # Revision History: # 20261031-01 - Added script header and YYYYMMDD-xx versioning. # Added strict error handling with error/interrupt logging; # an archive is no longer deleted if its backup fails. # Staging directory is cleaned up on failure or interrupt. # Restic snapshot details (ID, parent, tags, file/dir counts, # data added, snapshot size, duration) are now written to the # log. restic backup now runs with --json and the details are # read from its backup summary. Added totals at end of run. # Added required-command check. # set -o errexit set -o nounset set -o pipefail SCRIPT_VERSION="20261031-01" #------------------------------------------------------------------------------ # Configuration #------------------------------------------------------------------------------ LOG_FILE="/home/glibackup/openwrt-backup.log" SOURCE_DIR="/home/glibackup/openwrt" STAGING_DIR="/home/Backups/openwrt" TIMESTAMP="$(date +%Y%m%d-%H%M%S)" HOSTNAME_FQDN="$(hostname -f)" RESTIC_TAGS=(openwrt backup) # Restic repository configuration export RESTIC_REPOSITORY="s3:https://s3.us-west-004.backblazeb2.com/ackitsbucket/openwrt" export RESTIC_ENV_FILE="/root/.restic-env" RESTIC_OUTPUT="" STAGING_IN_USE="" #------------------------------------------------------------------------------ # Logging Function #------------------------------------------------------------------------------ log() { local message message="[$(date '+%F %T')] $*" echo "$message" echo "$message" >> "$LOG_FILE" } #------------------------------------------------------------------------------ # Convert a byte count to a human-readable size (e.g. 1.23GiB) #------------------------------------------------------------------------------ human_bytes() { local bytes="${1:-0}" if command -v numfmt >/dev/null 2>&1; then numfmt --to=iec-i --suffix=B --format="%.2f" "$bytes" 2>/dev/null \ || echo "${bytes} B" else echo "${bytes} B" fi } #------------------------------------------------------------------------------ # Write Restic snapshot details from a backup summary (JSON) to the log #------------------------------------------------------------------------------ log_snapshot_info() { local summary="$1" local snapshot_id parent_id local files_new files_changed files_unmodified local dirs_new dirs_changed dirs_unmodified local data_added data_added_packed local total_files total_bytes total_duration local tags snapshot_id=$(jq -r '.snapshot_id // "none"' <<< "$summary") parent_id=$(restic snapshots "$snapshot_id" --json 2>/dev/null \ | jq -r '.[0].parent // "none"' 2>/dev/null || echo "unknown") files_new=$(jq -r '.files_new // 0' <<< "$summary") files_changed=$(jq -r '.files_changed // 0' <<< "$summary") files_unmodified=$(jq -r '.files_unmodified // 0' <<< "$summary") dirs_new=$(jq -r '.dirs_new // 0' <<< "$summary") dirs_changed=$(jq -r '.dirs_changed // 0' <<< "$summary") dirs_unmodified=$(jq -r '.dirs_unmodified // 0' <<< "$summary") data_added=$(jq -r '.data_added // 0' <<< "$summary") data_added_packed=$(jq -r '.data_added_packed // empty' <<< "$summary") total_files=$(jq -r '.total_files_processed // 0' <<< "$summary") total_bytes=$(jq -r '.total_bytes_processed // 0' <<< "$summary") total_duration=$(jq -r '.total_duration // 0' <<< "$summary") tags=$(IFS=,; echo "${RESTIC_TAGS[*]}" | sed 's/,/, /g') log "---------------- Restic Snapshot -----------------" log "Snapshot ID: ${snapshot_id:0:8} (${snapshot_id})" log "Parent snapshot: ${parent_id:0:8}" log "Tags: ${tags}" log "Files: ${files_new} new, ${files_changed} changed, ${files_unmodified} unmodified" log "Directories: ${dirs_new} new, ${dirs_changed} changed, ${dirs_unmodified} unmodified" if [[ -n "$data_added_packed" ]]; then log "Data added: $(human_bytes "$data_added") ($(human_bytes "$data_added_packed") stored)" else log "Data added: $(human_bytes "$data_added")" fi log "Snapshot size: $(human_bytes "$total_bytes") in ${total_files} files" log "Duration: $(awk -v d="$total_duration" 'BEGIN{printf "%.1f", d}') seconds" log "--------------------------------------------------" } #------------------------------------------------------------------------------ # Cleanup Function #------------------------------------------------------------------------------ cleanup() { if [[ -n "${STAGING_IN_USE:-}" && -d "$STAGING_DIR" ]]; then log "Cleaning up staging directory ${STAGING_DIR}" rm -rf "$STAGING_DIR" || log "WARNING: Failed to remove ${STAGING_DIR}" STAGING_IN_USE="" fi if [[ -n "${RESTIC_OUTPUT:-}" ]]; then rm -f "$RESTIC_OUTPUT" RESTIC_OUTPUT="" fi } #------------------------------------------------------------------------------ # Error Handling #------------------------------------------------------------------------------ error_handler() { local exit_code=$? local line_number=$1 trap - ERR log "ERROR: Backup failed on line ${line_number} with exit code ${exit_code}" cleanup log "----------------------------------------------------" exit "${exit_code}" } interrupt_handler() { trap - SIGINT SIGTERM log "ERROR: Backup interrupted by signal" cleanup log "----------------------------------------------------" exit 1 } trap 'error_handler $LINENO' ERR trap interrupt_handler SIGINT SIGTERM #------------------------------------------------------------------------------ # Startup Logging #------------------------------------------------------------------------------ log "====================================================" log "OpenWrt Backup Starting (openwrtupload.sh v${SCRIPT_VERSION})" log "Host: ${HOSTNAME_FQDN}" log "Source Directory: ${SOURCE_DIR}" log "Log File: ${LOG_FILE}" log "====================================================" #------------------------------------------------------------------------------ # Verify Requirements #------------------------------------------------------------------------------ for cmd in restic jq awk tar; do if ! command -v "$cmd" >/dev/null 2>&1; then log "ERROR: Missing required command: $cmd" exit 1 fi done #------------------------------------------------------------------------------ # Load Restic Environment #------------------------------------------------------------------------------ # shellcheck source=/dev/null source "$RESTIC_ENV_FILE" #------------------------------------------------------------------------------ # Locate Archives #------------------------------------------------------------------------------ shopt -s nullglob ARCHIVES=("$SOURCE_DIR"/*.gz) shopt -u nullglob if [[ ${#ARCHIVES[@]} -eq 0 ]]; then log "No backup files found." log "----------------------------------------------------" exit 0 fi log "Found ${#ARCHIVES[@]} archive(s) to back up" #------------------------------------------------------------------------------ # Process Each Archive #------------------------------------------------------------------------------ TAG_ARGS=() for tag in "${RESTIC_TAGS[@]}"; do TAG_ARGS+=(--tag "$tag") done total_bytes_all=0 total_added_all=0 archive_count=0 for FILE in "${ARCHIVES[@]}"; do log "Backing up archive ${FILE}..." # Start from an empty staging directory rm -rf "$STAGING_DIR" mkdir -p "$STAGING_DIR" STAGING_IN_USE="yes" log "Extracting archive ${FILE} to ${STAGING_DIR}" tar xzf "$FILE" -C "$STAGING_DIR" log "Backing up ${STAGING_DIR} with restic" # Capture restic's JSON output in a temporary file. Errors and # warnings still go to the log via stderr. RESTIC_OUTPUT="$(mktemp /tmp/restic-backup-XXXXXX.json)" restic backup \ "$STAGING_DIR" \ --json \ "${TAG_ARGS[@]}" \ > "$RESTIC_OUTPUT" 2>> "$LOG_FILE" summary=$(jq -cR 'fromjson? | select(.message_type == "summary")' \ "$RESTIC_OUTPUT" | tail -n 1) rm -f "$RESTIC_OUTPUT" RESTIC_OUTPUT="" if [[ -n "$summary" ]]; then log_snapshot_info "$summary" snap_bytes=$(jq -r '.total_bytes_processed // 0' <<< "$summary") added_bytes=$(jq -r '.data_added // 0' <<< "$summary") else log "WARNING: Restic returned no snapshot summary for ${FILE}" snap_bytes=0 added_bytes=0 fi total_bytes_all=$(awk -v a="$total_bytes_all" -v b="$snap_bytes" 'BEGIN{printf "%.0f", a + b}') total_added_all=$(awk -v a="$total_added_all" -v b="$added_bytes" 'BEGIN{printf "%.0f", a + b}') archive_count=$((archive_count + 1)) log "Cleaning up staging directory ${STAGING_DIR}" rm -rf "$STAGING_DIR" STAGING_IN_USE="" log "Backup Complete." log "Removing archive file ${FILE}" rm -f "$FILE" log "----------------------------------------------------" done #------------------------------------------------------------------------------ # Completion #------------------------------------------------------------------------------ log "All archives backed up successfully." log "Archives Processed: ${archive_count}" log "Total Backup Size: $(human_bytes "$total_bytes_all")" log "Total New Data Uploaded: $(human_bytes "$total_added_all")" log "====================================================" exit 0