#! /bin/sh

# oob v0.4.1
# Online Offline Backup
# Copyright (c) 2018 Raphaël Halimi <raphael.halimi@gmail.com>
# Snapshot management heavily inspired from rsnapshot by Nathan Rosenquist

# Source shell-script-helper
. /lib/shell-script-helper


#
# Variables
#

# Configuration files
SYSTEM_CONFIG_FILE="/etc/$(basename -- "$0")/$(basename -- "$0").conf"
USER_CONFIG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/$(basename -- "$0")/$(basename -- "$0").conf"

# Options defaults
HOST_LIST=""
INTERVAL_LIST="hourly:24 daily:7 weekly:4 monthly:12 yearly:99"
SOURCE_DIR="/mnt/$(basename -- "$0")"
BACKUP_DIR="/var/lib/$(basename -- "$0")"
EXCLUDE_FROM="/etc/$(basename -- "$0")/exclude"
LOGFILE=""
LOGFILE_OWNER=""
LOGFILE_PERMS=""
DELAY_DELETE=1
TIMEOUT=30
VERBOSE=0
DEBUG=0
VERBOSE_TO_LOG=0
DEBUG_TO_LOG=0

# Internal variables
CP_OPTS="--archive --link"
RSYNC_OPTS="--archive --hard-links --acls --xattrs --numeric-ids --delete"
BACKUP_IN_PROGRESS=0
LOGFILE_READY=0


#
# Functions
#

on_exit () {
  if [ $BACKUP_IN_PROGRESS -eq 1 ] ; then
    remote_unmount
    delete_files "$EXCLUDE_FILE_TEMP"
  fi
}

remote_mount () {
  local DECODED
  remote_unmount
  print_verbose "Remote: creating directory \"$SOURCE_DIR\""
  $SSH mkdir $SOURCE_DIR
  for DIR in $MOUNT_LIST ; do
    print_debug "DIR=$DIR"
    DECODED=$(fstab-decode echo $DIR)
    print_debug "DECODED=$DECODED"
    print_verbose "Remote: bind-mounting directory \"$DECODED\" on \"$SOURCE_DIR$DECODED\""
    $SSH mount -o bind "$DECODED" "$SOURCE_DIR$DECODED"
  done
}

remote_unmount () {
  local DECODED
  if $SSH test -d $SOURCE_DIR ; then
    for DIR in $UMOUNT_LIST ; do
      print_debug "DIR=$DIR"
      DECODED=$(fstab-decode echo $DIR)
      print_debug "DECODED=$DECODED"
      print_verbose "Remote: unmounting \"$SOURCE_DIR$DECODED\""
      $SSH umount "$SOURCE_DIR$DECODED"
    done
    print_verbose "Remote: removing directory \"$SOURCE_DIR\""
    $SSH rmdir $SOURCE_DIR
  fi
}

interval_valid () {
  printf "%s" "$INTERVAL_LIST" | grep -qw "$1" || return 1
}

interval_lowest () {
  local LOWEST
  LOWEST="$(printf "%s" "$INTERVAL_LIST" | cut -d " " -f 1 | cut -d ":" -f 1)"
  printf "%s" "$LOWEST"
}

interval_lower () {
  local SEARCH COUNTER
  SEARCH="$1"
  COUNTER=1
  while true ; do
    if printf "%s" "$INTERVAL_LIST" | cut -d " " -f $COUNTER | grep -qw "$SEARCH" ; then
      if [ $COUNTER -eq 1 ] ; then
        return 1
      else
        PREVIOUS="$(printf "%s" "$INTERVAL_LIST" | cut -d " " -f $((COUNTER-1)) | cut -d ":" -f 1)"
        printf "%s" "$PREVIOUS"
        return 0
      fi
    fi
    COUNTER=$((COUNTER+1))
  done
}

interval_number () {
  printf "%s" "$INTERVAL_LIST" | sed -E -e "s/^.*$1:([[:digit:]]+).*$/\1/"
}

snapshot_delete () {
  local DELETE_DIR MSG_TYPE
  DELETE_DIR="$1"
  MSG_TYPE="$2"
  print_debug "DELETE_DIR=$DELETE_DIR" "MSG_TYPE=$MSG_TYPE"
  if [ -d "$DELETE_DIR" ] ; then
    if [ $DELAY_DELETE -eq 1 ] ; then
      if [ "$MSG_TYPE" = "abnormal" ] ; then
        print_error "Snapshot \"$DELETE_DIR\" exists, whereas it should not, marking for deletion"
      else
        print_verbose "Marking snapshot $(basename -- "$DELETE_DIR") for deletion"
      fi
      DIRS_TO_DELETE="$DIRS_TO_DELETE ${DELETE_DIR}.delete"
      mv "$DELETE_DIR" "${DELETE_DIR}.delete"
    else
      if [ "$MSG_TYPE" = "abnormal" ] ; then
        print_error "Snapshot \"$DELETE_DIR\" exists, whereas it should not, deleting"
      else
        print_verbose "Deleting snapshot $(basename -- "$DELETE_DIR")"
      fi
      rm -rf "$DELETE_DIR"
    fi
  fi
}

delayed_delete () {
  local DELETE_DIR
  if [ $DELAY_DELETE -eq 1 ] ; then
    if [ -n "$DIRS_TO_DELETE" ] ; then
      print_verbose "Starting delayed deletion of marked snapshots"
      DELAYED_DELETE_START=$(date +%s)
      for DELETE_DIR in $DIRS_TO_DELETE ; do
        print_verbose "Deleting \"$DELETE_DIR\""
        rm -rf "$DELETE_DIR"
      done
      DELAYED_DELETE_END=$(date +%s)
      print_verbose "Finished delayed deletion of marked snapshots ($(convert_seconds $((DELAYED_DELETE_END-DELAYED_DELETE_START))))"
    fi
  fi
}

snapshot_rotate () {
  local OLD_DIR NEW_DIR
  OLD_DIR="$1"
  NEW_DIR="$2"
  print_debug "OLD_DIR=$OLD_DIR" "NEW_DIR=$NEW_DIR"
  [ -d "$NEW_DIR" ] && snapshot_delete "$NEW_DIR" "abnormal"
  if [ -d "$OLD_DIR" ] ; then
    print_verbose "Rotating snapshot $(basename -- "$OLD_DIR") to $(basename -- "$NEW_DIR")"
    mv "$OLD_DIR" "$NEW_DIR"
  fi
}

convert_seconds () {
  local TIME CONVERTED H M S
  TIME="$1"
  CONVERTED=""
  # TODO: check if $TIME is composed only of digits
  H="$((TIME/3600))"
  M="$(($((TIME/60))%60))"
  S="$((TIME%60))"
  [ $H -ne 0 ] && CONVERTED="$CONVERTED$(printf "%dh" "$H")"
  [ $M -ne 0 ] && CONVERTED="$CONVERTED$(printf "%dm" "$M")"
  [ $S -ne 0 ] && CONVERTED="$CONVERTED$(printf "%ds" "$S")"
  [ -z "$CONVERTED" ] && CONVERTED="0s"
  printf "%s\n" "$CONVERTED"
}

create_logfile () {
  if ( [ $VERBOSE_TO_LOG -eq 1 ] || [ $DEBUG_TO_LOG -eq 1 ] ) ; then
    if [ -n "$LOGFILE" ] ; then
      if [ ! -e "$LOGFILE" ] ; then
        touch "$LOGFILE"
        [ -n "$LOGFILE_OWNER" ] && chown "$LOGFILE_OWNER" "$LOGFILE"
        [ -n "$LOGFILE_PERMS" ] && chmod "$LOGFILE_PERMS" "$LOGFILE"
      fi
      exec 3>> "$LOGFILE"
      if [ $? -ne 0 ] ; then
        VERBOSE_TO_LOG=0 ; DEBUG_TO_LOG=0
        print_error "Cannot open log file \"$LOGFILE\" for writing, not using log file"
      else
        LOGFILE_READY=1
      fi
    else
      VERBOSE_TO_LOG=0 ; DEBUG_TO_LOG=0
      print_error "LOGFILE parameter not defined, not using log file"
    fi
  fi
}

log_to_file () {
  local MESSAGE
  MESSAGE="$1"
  [ $LOGFILE_READY -eq 1 ] && printf "%s %s %s[%s]: %s\n" "$(LANG=C date +'%b %e %H:%M:%S')" "$(hostname)" "$(basename -- "$0")" "$$" "$MESSAGE" >&3
}

print_verbose () {
  local MESSAGE
  if [ $VERBOSE -eq 1 ] ; then
    for MESSAGE in "$@" ; do
      printf "%-8s%s\n" "[INFO]" "$MESSAGE"
    done
  fi
  if [ $VERBOSE_TO_LOG -eq 1 ] ; then
    for MESSAGE in "$@" ; do
      log_to_file "$MESSAGE"
    done
  fi
}

print_debug () {
  local MESSAGE
  if [ $DEBUG -eq 1 ] ; then
    for MESSAGE in "$@" ; do
      printf "%-8s%s\n" "[DEBUG]" "$MESSAGE"
    done
  fi
  if [ $DEBUG_TO_LOG -eq 1 ] ; then
    for MESSAGE in "$@" ; do
      log_to_file "DEBUG: $MESSAGE"
    done
  fi
}

print_error () {
  local MESSAGE
  for MESSAGE in "$@" ; do
    log_to_file "ERROR: $MESSAGE"
    printf "%-8s%s\n" "[ERROR]" "$MESSAGE" >&2
  done
}

print_error_host () {
  local PROGRAM HOST
  PROGRAM="$1"
  HOST="$2"
  print_error "$PROGRAM returned non-zero exit code on host $HOST"
}

print_usage () {
  printf "Usage: %s [OPTION]... [INTERVAL]\n" "$(basename -- "$0")"
  printf "Online Offline Backup\n"
  printf "\nOPTIONS:\n"
  print_option "-v" "Verbose mode"
  print_option "-d" "Debug mode"
  print_option "-h" "Print this help message"
  printf "\nINTERVAL:\n"
  printf "One of: %s (default: %s)\n" \
    "$(printf "%s" "$INTERVAL_LIST" | sed -E -e "s/:[[:digit:]]+/,/g" -e "s/,$//")" \
    "$(printf "%s" "$INTERVAL_LIST" | sed -e "s/:.*$//")"
}


#
# Configuration files
#

[ -e "$SYSTEM_CONFIG_FILE" ] && . "$SYSTEM_CONFIG_FILE"
[ -e "$USER_CONFIG_FILE" ] && . "$USER_CONFIG_FILE"


#
# Options processing
#

while getopts "vdh" OPTION ; do
  case $OPTION in
    v) enable_verbose ;;
    d) enable_debug ;;
    h) print_usage ; exit 0 ;;
    *) print_usage ; exit 1 ;;
  esac
done ; shift $((OPTIND-1))


#
# Checks
#

root_only
lock_script
create_logfile

if [ $# -eq 0 ] ; then
  INTERVAL="$(interval_lowest)"
else
  INTERVAL="$1"
  interval_valid "$INTERVAL" || die "Interval $INTERVAL doesn't exist"
  [ $# -gt 1 ] && print_error "$(basename -- "$0") expects at most one argument, ignoring everything after \"$1\""
fi

[ -d "$BACKUP_DIR" ] || die "Backup directory $BACKUP_DIR doesn't exist, please create it manually"


#
# Set options for cp and rsync
#

[ $DEBUG = 1 ] && CP_OPTS="$CP_OPTS --verbose" && RSYNC_OPTS="$RSYNC_OPTS --verbose --human-readable"
for EXCLUDE_ITEM in "$SOURCE_DIR" "$BACKUP_DIR" ; do RSYNC_OPTS="$RSYNC_OPTS --exclude $EXCLUDE_ITEM" ; done


#
# Options summary
#

print_debug "HOST_LIST=$HOST_LIST" \
"INTERVAL_LIST=$INTERVAL_LIST" \
"SOURCE_DIR=$SOURCE_DIR" \
"BACKUP_DIR=$BACKUP_DIR" \
"EXCLUDE_FROM=$EXCLUDE_FROM" \
"LOGFILE=$LOGFILE" \
"LOGFILE_PERMS=$LOGFILE_PERMS" \
"LOGFILE_OWNER=$LOGFILE_OWNER" \
"LOGFILE_READY=$LOGFILE_READY" \
"TIMEOUT=$TIMEOUT" \
"VERBOSE=$VERBOSE" \
"VERBOSE_TO_LOG=$VERBOSE_TO_LOG" \
"DEBUG=$DEBUG" \
"DEBUG_TO_LOG=$DEBUG_TO_LOG" \
"CP_OPTS=$CP_OPTS" \
"RSYNC_OPTS=$RSYNC_OPTS" \
"EXCLUDE_LIST=$EXCLUDE_LIST" \
"BACKUP_IN_PROGRESS=$BACKUP_IN_PROGRESS"


#
# MAIN
#

TOTAL_START=$(date +%s)
print_verbose "Starting $(basename -- "$0") ($INTERVAL)"
print_verbose "Using directory \"$BACKUP_DIR\" for backups"

# Loop through all hosts in HOST_LIST
for HOST in $HOST_LIST ; do

  HOST_START=$(date +%s)

  # Try to resolve the hostname to a FQDN
  HOST_FQDN="$(host $HOST | sed -E "/has( IPv6)* address/!d ; s/[[:blank:]]+.*//" | head -n 1)"
  print_debug "HOST=$HOST" "HOST_FQDN=$HOST_FQDN"

  # Bail if the FQDN can't be resolved, else we continue
  if [ -z "$HOST_FQDN" ] ; then
    print_error "Can't resolve name \"$HOST\""
  else
    print_verbose "Host \"$HOST\" resolves to \"$HOST_FQDN\""
    HOST_DIR="$BACKUP_DIR/$HOST_FQDN"
    SSH="ssh -o ConnectTimeout=$TIMEOUT $HOST"
    print_debug "SSH=$SSH" "HOST_DIR=$HOST_DIR"


    #
    # Rotate
    #

    # Obviously we can rotate only if previous snapshots have been made
    if [ -d "$HOST_DIR" ] ; then
      # TODO: check if snapshots > interval_number exist (if user changed intervals)
      ROTATE_START=$(date +%s)
      print_verbose "Starting $INTERVAL rotation for host \"$HOST\""

      # Get the number of snapshots to keep for this interval
      INTERVAL_NUMBER=$(interval_number "$INTERVAL")
      print_debug "INTERVAL_NUMBER=$INTERVAL_NUMBER"

      # Loop through snapshots, starting from the highest (normally the oldest)
      COUNTER=$INTERVAL_NUMBER
      while [ $COUNTER -ge 0 ] ; do
        print_debug "COUNTER=$COUNTER"

        # Delete the highest
        if [ $COUNTER -eq $INTERVAL_NUMBER ] ; then
          snapshot_delete "$HOST_DIR/${INTERVAL}.$COUNTER"

        # Pull the oldest snapshot from the lower interval
        # Example: oldest daily becomes newest monthly
        elif [ $COUNTER -eq 0 ] ; then
          if [ "$INTERVAL" != "$(interval_lowest)" ] ; then
            LOWER_INTERVAL="$(interval_lower "$INTERVAL")"
            OLDEST_LOWER="$(ls -dt "$HOST_DIR/$LOWER_INTERVAL".* 2>/dev/null | tail -n 1)"
            print_debug "LOWER_INTERVAL=$LOWER_INTERVAL" "OLDEST_LOWER=$OLDEST_LOWER"
            [ -d "$OLDEST_LOWER" ] && snapshot_rotate "$OLDEST_LOWER" "$HOST_DIR/${INTERVAL}.$((COUNTER+1))"
          fi

        # Snapshots between newest and lowest are just rotated
        else
          snapshot_rotate "$HOST_DIR/${INTERVAL}.$COUNTER" "$HOST_DIR/${INTERVAL}.$((COUNTER+1))"
        fi

        # Decrease counter, and loop
        COUNTER=$((COUNTER-1))
      done

      ROTATE_END=$(date +%s)
      print_verbose "Finished $INTERVAL rotation for host \"$HOST\" ($(convert_seconds $((ROTATE_END-ROTATE_START))))"
    fi


    #
    # Link & Sync
    #

    # Snapshots are taken only for the lowest interval
    if [ "$INTERVAL" = "$(interval_lowest)" ] ; then

      # Bail if remote host can't accept batch ssh commands, else we continue
      print_verbose "Trying to contact host \"$HOST\""
      if ! $SSH -o BatchMode=yes true ; then
        print_error "Can't run batch commands on host \"$HOST\""
      else
        BACKUP_IN_PROGRESS=1
        print_debug "BACKUP_IN_PROGRESS=$BACKUP_IN_PROGRESS"
        print_verbose "Starting backup for host \"$HOST\""

        # Make a list of partitions to mount/unmount
        MOUNT_LIST=$($SSH cat /proc/mounts | sed -E '/^\/dev/!d ; s/^[^[:blank:]]+[[:blank:]]+([^[:blank:]]+).*$/\1/ ; /^\/(mnt|media|tmp)/d')
        UMOUNT_LIST=$(printf "%s\n" "$MOUNT_LIST" | tac)
        print_debug "MOUNT_LIST=$MOUNT_LIST"
        print_debug "UMOUNT_LIST=$UMOUNT_LIST"

        # Mount partitions on remote host
        remote_mount

        # This is the directory where the snapshot will be taken
        TARGET_DIR="$HOST_DIR/${INTERVAL}.1"
        print_debug "TARGET_DIR=$TARGET_DIR"

        # If HOST_DIR exists, link the newest snapshot, else create HOST_DIR
        if [ -d $HOST_DIR ] ; then
          NEWEST_SNAPSHOT="$(ls -dt "$HOST_DIR/"* 2>/dev/null | head -n 1)"
          print_debug "NEWEST_SNAPSHOT=$NEWEST_SNAPSHOT"
          if [ -d "$NEWEST_SNAPSHOT" ] ; then
            print_verbose "Starting hard-linking $(basename -- "$NEWEST_SNAPSHOT") to $(basename -- "$TARGET_DIR")"
            LINK_START=$(date +%s)
            cp $CP_OPTS "$NEWEST_SNAPSHOT" "$TARGET_DIR"
            [ $? -eq 0 ] || print_error_host "cp" "$HOST"
            LINK_END=$(date +%s)
            print_verbose "Finished hard-linking for host \"$HOST\" ($(convert_seconds $((LINK_END-LINK_START))))"
          else
            print_error "Host directory \"$HOST_DIR\" exists, but no previous backup was found, this shouldn't happen"
          fi
        else
          print_verbose "Creating host directory \"$HOST_DIR\""
          mkdir "$HOST_DIR"
        fi

        # Load default and custom exclude files
        EXCLUDE_FILE_TEMP="$(mktemp /tmp/oob_exclude_XXXXXXXX)"
        for EXCLUDE_FILE in $EXCLUDE_FROM ${EXCLUDE_FROM}.$HOST ; do
          print_debug "EXCLUDE_FILE=$EXCLUDE_FILE"
          if [ -f "$EXCLUDE_FILE" ] ; then
            print_verbose "Using exclude file \"$EXCLUDE_FILE\""
            cat "$EXCLUDE_FILE" >> "$EXCLUDE_FILE_TEMP"
          fi
        done

        if [ -s "$EXCLUDE_FILE_TEMP" ] ; then
          HOST_RSYNC_OPTS="$RSYNC_OPTS --exclude-from $EXCLUDE_FILE_TEMP"
          print_debug "HOST_RSYNC_OPTS=$HOST_RSYNC_OPTS"
        fi

        # Now that everything is set, we can finally run rsync
        print_verbose "Starting synchronization for host \"$HOST\""
        print_verbose "Synchronizing \"$HOST:$SOURCE_DIR/\" to \"$TARGET_DIR/\""
        SYNC_START=$(date +%s)
        rsync $HOST_RSYNC_OPTS $HOST:$SOURCE_DIR/ $TARGET_DIR/
        [ $? -eq 0 ] || print_error_host "rsync" "$HOST"
        delete_files "$EXCLUDE_FILE_TEMP"
        SYNC_END=$(date +%s)
        print_verbose "Finished synchronization for host \"$HOST\" ($(convert_seconds $((SYNC_END-SYNC_START))))"
        touch $TARGET_DIR

        # Unmount partitions on remote host
        remote_unmount

        BACKUP_IN_PROGRESS=0
        print_debug "BACKUP_IN_PROGRESS=$BACKUP_IN_PROGRESS"
      fi
    fi
  fi
  HOST_END=$(date +%s)
  print_verbose "Finished backup for host \"$HOST\" ($(convert_seconds $((HOST_END-HOST_START))))"
done

if [ "$INTERVAL" = "$(interval_lowest)" ] ; then
  BACKUP_END=$(date +%s)
  print_verbose "Finished backup for all hosts ($(convert_seconds $((BACKUP_END-TOTAL_START))))"
fi

delayed_delete

TOTAL_END=$(date +%s)
print_verbose "Finished $(basename -- "$0") ($INTERVAL) ($(convert_seconds $((TOTAL_END-TOTAL_START))))"
