#!/bin/bash
#
# Author: Christophe Casalegno / Brain 0verride
# Contact: christophe.casalegno@scalarx.com
# SxFilesBkp / ScalarX File Backup
# Version 1.1
#
# Copyright (c) 2020 Christophe Casalegno
#
# This program is free software: you can redistribute it and/or modify
#
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, either version 3 of the License, or
#    (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program.  If not, see <https://www.gnu.org/licenses/>
#
# The license is available on this server here:
# https://www.christophe-casalegno.com/licences/gpl-3.0.txt

set -euo pipefail
IFS=$'\n\t'

PROG="${0##*/}"

# Defaults values can be overridden in config file

CONF_FILE="/etc/sx/sxfilesbkp/sxfilesbkp.cfg"
DIR_LIST_FILE="/etc/sx/sxfilesbkp/sxfilesbkp.dirs"

RDIFF_BIN="rdiff-backup"
SSH_BIN="ssh"

LOCKDIR="/run/lock"
LOGDIR="/var/log/sxfilesbkp"

# Remote destination (rdiff-backup server over ssh)
# ${REMOTE_USER}@${REMOTE_HOST}::${REMOTE_ROOT}/${REMOTE_TAG}/${REL_PATH}

REMOTE_HOST="" ; REMOTE_USER=""
REMOTE_ROOT="" ; REMOTE_TAG=""

# Override how rdiff-backup invokes the remote side.
# If empty, rdiff-backup uses its default remote schema.
# Example (new CLI on remote): ssh -C {h} rdiff-backup --new server
REMOTE_SCHEMA=""

# Retention policy, rdiff-backup time format: 30D, 12W, 6M, 1Y, 20B...
RETENTION_REMOTE="30D"

# rdiff-backup options

VERBOSITY="3"               ; PRINT_STATS="yes"
CREATE_FULL_PATH="yes"      ; EXCLUDE_OTHER_FILESYSTEMS="yes"
EXCLUDE_SPECIAL_FILES="yes" ; EXCLUDE_SYMLINKS="no"

# Optional globbing exclusion file (one glob per line, rdiff-backup syntax)
EXCLUDE_GLOBS_FILE=""

# Exclude directories containing this marker file
EXCLUDE_IF_PRESENT=".nosxbackup"

# Options for optimisation performances / load
USE_NICE="yes"   ; NICE_LEVEL="10"
USE_IONICE="yes" ; IONICE_CLASS="2" ; IONICE_LEVEL="7"

# Safety (yes to really want to backup /)
ALLOW_SOURCE_ROOT="no"   

# Runtime flags
DRY_RUN="no" ; DO_PURGE="yes" ; DO_TEST_REMOTE="no"

# Action
ACTION="backup"

# Internal
_RDIFF_HAS_ACTIONS="no"

function log_ts()
{
    date '+%Y-%m-%d %H:%M:%S'
}

function log()
{
    local level="$1"
    shift
    printf '[%s] [%s] %s\n' "$(log_ts)" "$level" "$*"
}

function die()
{
    log "ERR" "$*"
    exit 1
}

function require_cmd()
{
    local cmd="$1"

    command -v "$cmd" >/dev/null 2>&1 || die "Missing required command: $cmd"
}

function root_check()
{
    if [[ "${EUID}" -ne 0 ]]
    then
        die "This script must be run as root."
    fi
}

function lock_check()
{
    command -v flock >/dev/null 2>&1 || { echo "Error: flock not found." >&2; exit 1; }

    local LOCKDIR="/run/lock"
    mkdir -p "$LOCKDIR" 2>/dev/null || true

    local NAME
    NAME="${0##*/}"
    local LOCKFILE="$LOCKDIR/${NAME}.lock"

    if [[ -L "$LOCKFILE" ]]
    then
        echo "Error: lockfile is a symlink ($LOCKFILE)" >&2
        exit 1
    fi

    if [[ -e "$LOCKFILE" && ! -f "$LOCKFILE" ]]
    then
        echo "Error: lockfile is not a regular file ($LOCKFILE)" >&2
        exit 1
    fi

    if [[ -e "$LOCKFILE" ]]
    then
        local OWNER_UID
        OWNER_UID="$(stat -c %u "$LOCKFILE" 2>/dev/null)" || { echo "Error: Cannot stat $LOCKFILE" >&2; exit 1; }
        if [[ "$OWNER_UID" != "0" ]]
        then
            echo "Error: lockfile owner is not root ($LOCKFILE, uid=$OWNER_UID)" >&2
            exit 1
        fi
    fi

    local OLDUMASK
    OLDUMASK="$(umask)"
    umask 077

    local LOCKFD
    exec {LOCKFD}<>"$LOCKFILE" || { echo "Error: Cannot open lock $LOCKFILE" >&2; umask "$OLDUMASK"; exit 1; }

    if ! flock -n "$LOCKFD"
    then
        local PID
        PID="$(cat "$LOCKFILE" 2>/dev/null)"
        echo "Error: $NAME is already running (PID ${PID:-unknown})." >&2
        umask "$OLDUMASK"
        exit 1
    fi

    if [[ -w "/proc/self/fd/$LOCKFD" ]]
    then
        : > "/proc/self/fd/$LOCKFD"
    else
        : > "$LOCKFILE"
    fi

    printf '%s\n' "$$" >&"$LOCKFD"

    umask "$OLDUMASK"
}

function trim()
{
    local s="$1"

    # trim leading                 ; trim trailing
    s="${s#"${s%%[![:space:]]*}"}" ; s="${s%"${s##*[![:space:]]}"}"
    
    printf '%s' "$s"
}

function read_config()
{
    local file="$1"

    if [[ ! -f "$file" ]]
    then
        die "Config file not found: $file"
    fi

    while IFS= read -r line || [[ -n "$line" ]]
    do
        # Strip comments
        line="${line%%#*}"
        line="$(trim "$line")"

        if [[ -z "$line" ]]
        then
            continue
        fi

        if [[ "$line" != *:* ]]
        then
            die "Invalid config line (missing ':'): $line"
        fi

        local key value
        key="${line%%:*}"
        value="${line#*:}"

        key="$(trim "$key")"
        value="$(trim "$value")"

        key="${key^^}"

        case "$key" in
            ''|*[!A-Z0-9_]*)
                die "Invalid config key: $key"
            ;;
        esac

	# Assign without eval (used in previous stackx version)
        printf -v "$key" '%s' "$value"
    done < "$file"
}

function usage()
{
    cat <<EOF
Usage: $PROG [options]

Options:
  -c, --config FILE        Config file (default: $CONF_FILE)
  -l, --list FILE          Directory list file (default: $DIR_LIST_FILE)

  --dry-run                Do not change destination (best-effort, rdiff still connects)
  --no-purge               Do not remove old increments
  --purge-only             Only purge increments (no backup)
  --test                   Run remote compatibility test first (rdiff-backup test)

  -h, --help               Show this help

Directory list format (one per line):
  /etc
  /home
  /var/www
Lines starting with '#' are ignored.

Config keys (sxfilesbkp-style 'KEY:VALUE'):
  REMOTE_HOST, REMOTE_USER, REMOTE_ROOT, REMOTE_TAG
  RETENTION_REMOTE
  VERBOSITY, PRINT_STATS
  CREATE_FULL_PATH
  EXCLUDE_OTHER_FILESYSTEMS, EXCLUDE_SPECIAL_FILES, EXCLUDE_SYMLINKS
  EXCLUDE_GLOBS_FILE, EXCLUDE_IF_PRESENT
  USE_NICE, NICE_LEVEL, USE_IONICE, IONICE_CLASS, IONICE_LEVEL
  ALLOW_SOURCE_ROOT
EOF
}

function parse_args()
{
    while [[ $# -gt 0 ]]
    do
        case "$1" in
            -c|--config)
                CONF_FILE="$2"
                shift 2
            ;;
            -l|--list)
                DIR_LIST_FILE="$2"
                shift 2
            ;;
            --dry-run)
                DRY_RUN="yes"
                shift
            ;;
            --no-purge)
                DO_PURGE="no"
                shift
            ;;
            --purge-only)
                DO_PURGE="yes"
                ACTION="purge"
                shift
            ;;
            --test)
                DO_TEST_REMOTE="yes"
                shift
            ;;
            -h|--help)
                usage
                exit 0
            ;;
            *)
                die "Unknown option: $1"
            ;;
        esac
    done
}

function rdiff_detect_actions()
{
    if "$RDIFF_BIN" backup --help >/dev/null 2>&1
    then
        _RDIFF_HAS_ACTIONS="yes"
    else
        _RDIFF_HAS_ACTIONS="no"
    fi
}

function rdiff_cmd()
{
    # Wrap with nice/ionice if enabled
    local -a cmd
    cmd=()

    if [[ "$USE_IONICE" == "yes" ]]
    then
        if command -v ionice >/dev/null 2>&1
        then
            cmd+=("ionice" "-c" "$IONICE_CLASS" "-n" "$IONICE_LEVEL")
        fi
    fi

    if [[ "$USE_NICE" == "yes" ]]
    then
        if command -v nice >/dev/null 2>&1
        then
            cmd+=("nice" "-n" "$NICE_LEVEL")
        fi
    fi

    cmd+=("$RDIFF_BIN")

    printf '%s\n' "${cmd[@]}"
}

function build_generic_opts()
{
    local -a opts
    opts=()

    if [[ "$_RDIFF_HAS_ACTIONS" == "yes" ]]
    then
        opts+=("--new")

        if [[ -z "$REMOTE_SCHEMA" ]]
        then
            REMOTE_SCHEMA="ssh -C {h} rdiff-backup --new server"
        fi
    fi

    if [[ -n "$REMOTE_SCHEMA" ]]
    then
        opts+=("--remote-schema" "$REMOTE_SCHEMA")
    fi

    opts+=("--verbosity" "$VERBOSITY")

    printf '%s\n' "${opts[@]}"
}

function build_backup_opts()
{
    local -a opts
    opts=()

    if [[ "$PRINT_STATS" == "yes" ]]
    then
        opts+=("--print-statistics")
    else
        opts+=("--no-print-statistics")
    fi

    if [[ "$CREATE_FULL_PATH" == "yes" ]]
    then
        opts+=("--create-full-path")
    fi

    if [[ "$EXCLUDE_OTHER_FILESYSTEMS" == "yes" ]]
    then
        opts+=("--exclude-other-filesystems")
    fi

    if [[ -n "$EXCLUDE_IF_PRESENT" ]]
    then
        opts+=("--exclude-if-present" "$EXCLUDE_IF_PRESENT")
    fi

    if [[ -n "$EXCLUDE_GLOBS_FILE" ]]
    then
        if [[ ! -f "$EXCLUDE_GLOBS_FILE" ]]
        then
            die "EXCLUDE_GLOBS_FILE not found: $EXCLUDE_GLOBS_FILE"
        fi
        opts+=("--exclude-globbing-filelist" "$EXCLUDE_GLOBS_FILE")
    fi

    if [[ "$EXCLUDE_SPECIAL_FILES" == "yes" ]]
    then
        opts+=("--exclude-special-files")
    fi

    if [[ "$EXCLUDE_SYMLINKS" == "yes" ]]
    then
        opts+=("--exclude-symbolic-links")
    fi

    if [[ "$DRY_RUN" == "yes" ]]
    then
        opts+=("--dry-run")
    fi

    printf '%s\n' "${opts[@]}"
}

function remote_location()
{
    local rel="$1"

    if [[ -z "$REMOTE_HOST" || -z "$REMOTE_USER" || -z "$REMOTE_ROOT" || -z "$REMOTE_TAG" ]]
    then
        die "Remote is not fully configured (REMOTE_HOST/REMOTE_USER/REMOTE_ROOT/REMOTE_TAG)"
    fi

    local root tag
    
    root="${REMOTE_ROOT%/}" ;     tag="${REMOTE_TAG#/}"
    tag="${tag%/}"          ;     rel="${rel#/}"

    printf '%s@%s::%s/%s/%s' "$REMOTE_USER" "$REMOTE_HOST" "$root" "$tag" "$rel"
}

function validate_source()
{
    local src="$1"

    if [[ "$src" == "/" && "$ALLOW_SOURCE_ROOT" != "yes" ]]
    then
        die "Refusing to backup '/' (set ALLOW_SOURCE_ROOT:yes if you really want this)"
    fi

    if [[ ! -e "$src" ]]
    then
        die "Source path not found: $src"
    fi

    if [[ ! -d "$src" ]]
    then
        die "Source path is not a directory: $src"
    fi
}

function list_dirs()
{
    local file="$1"

    if [[ ! -f "$file" ]]
    then
        die "Directory list file not found: $file"
    fi

    while IFS= read -r line || [[ -n "$line" ]]
    do
        line="${line%%#*}"
        line="$(trim "$line")"

        if [[ -z "$line" ]]
        then
            continue
        fi

        printf '%s\n' "$line"
    done < "$file"
}

function normalize_rel()
{
    local rel="$1"

    rel="${rel#/}" ; rel="${rel%/}"

    if [[ -z "$rel" ]]
    then
        rel="root"
    fi

    printf '%s' "$rel"
}

function rdiff_test_remote()
{
    local loc="$1"

    if [[ "$_RDIFF_HAS_ACTIONS" == "yes" ]]
    then
        local -a cmd generic
        mapfile -t cmd < <(rdiff_cmd)
        mapfile -t generic < <(build_generic_opts)
        "${cmd[@]}" "${generic[@]}" test "$loc" >/dev/null
    else
	# Old CLI version support (like sxbackup)
        "$RDIFF_BIN" --test-server "$loc" >/dev/null
    fi
}

function run_backup_one()
{
    local src="$1"
    local rel="$2"
    local dst
    dst="$(remote_location "$rel")"

    validate_source "$src"

    local -a cmd generic bkop
    mapfile -t cmd < <(rdiff_cmd)
    mapfile -t generic < <(build_generic_opts)
    mapfile -t bkop < <(build_backup_opts)

    log "INF" "Backup: $src -> $dst"

    if [[ "$_RDIFF_HAS_ACTIONS" == "yes" ]]
    then
        "${cmd[@]}" "${generic[@]}" backup "${bkop[@]}" "$src" "$dst"
    else
        # Fallback Deprecated CLI for compatibility
        "${cmd[@]}" "${generic[@]}" "$src" "$dst"
    fi
}

function run_purge_one()
{
    local rel="$1"
    local dst
    dst="$(remote_location "$rel")"

    log "INF" "Purge: older-than $RETENTION_REMOTE on $dst"

    if [[ "$_RDIFF_HAS_ACTIONS" == "yes" ]]
    then
        local -a cmd generic
        mapfile -t cmd < <(rdiff_cmd)
        mapfile -t generic < <(build_generic_opts)

        # In the past I've issue like Debianregressions where "remove increments" can exit non-zero when there's nothing to remove. We'll treat RC=2 as a warning (not fatal) for purge.
        
	set +e
        
	"${cmd[@]}" "${generic[@]}" --force remove increments --older-than "$RETENTION_REMOTE" "$dst"
        local rc=$?
        set -e

        if [[ $rc -ne 0 && $rc -ne 2 ]]
        then
            die "Purge failed for $dst (rc=$rc)"
        elif [[ $rc -eq 2 ]]
        then
            log "WRN" "Purge returned rc=2 for $dst (often means nothing to remove); continuing"
        fi
    else
        "${RDIFF_BIN}" --remove-older-than "$RETENTION_REMOTE" --force "$dst"
    fi
}

function setup_logging()
{
    mkdir -p "$LOGDIR" 2>/dev/null || true

    local ts
    ts="$(date '+%Y%m%d-%H%M%S')"

    local logfile="$LOGDIR/${PROG}-${ts}.log"

    # Mirror stdout/stderr into logfile
    exec > >(tee -a "$logfile") 2>&1

    log "INF" "Logging to $logfile"
}

function main()
{
    parse_args "$@"

    root_check
    lock_check

    setup_logging

    require_cmd "$RDIFF_BIN"

    read_config "$CONF_FILE"

    if [[ -n "${DIR_LIST_FILE:-}" && -f "${DIR_LIST_FILE}" ]]
    then
        :
    else
        die "Directory list file not found: $DIR_LIST_FILE"
    fi

    rdiff_detect_actions

    log "INF" "rdiff-backup actions syntax: $_RDIFF_HAS_ACTIONS"

    if [[ "$DO_TEST_REMOTE" == "yes" ]]
    then
        local probe
        probe="$(remote_location ".")"
        log "INF" "Testing remote location: $probe"
        rdiff_test_remote "$probe" || die "Remote test failed"
        log "INF" "Remote test OK"
    fi

    local -a dirs
    mapfile -t dirs < <(list_dirs "$DIR_LIST_FILE")

    if [[ ${#dirs[@]} -eq 0 ]]
    then
        die "No directories to backup (empty list file)"
    fi

    local d rel
    case "$ACTION" in
        backup)
            for d in "${dirs[@]}"
            do
                rel="$(normalize_rel "$d")"

                run_backup_one "$d" "$rel"
            done

            if [[ "$DO_PURGE" == "yes" ]]
            then
                for d in "${dirs[@]}"
                do
                    rel="$(normalize_rel "$d")"

                    run_purge_one "$rel"
                done
            fi
        ;;
        purge)
            for d in "${dirs[@]}"
            do
                rel="$(normalize_rel "$d")"

                run_purge_one "$rel"
            done
        ;;
        *)
            die "Invalid action: $ACTION"
        ;;
    esac

    log "INF" "Done"
}

main "$@"
