Initial commit; base scripting and systemd user event code for borg based backup

This commit is contained in:
2026-07-10 12:20:13 +02:00
commit ffe3c354be
12 changed files with 984 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
APP_NAME="nextcloud-borg-backup"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BIN_DIR="${HOME}/.local/bin"
CONFIG_DIR="${XDG_CONFIG_HOME:-${HOME}/.config}/${APP_NAME}"
STATE_DIR="${XDG_STATE_HOME:-${HOME}/.local/state}/${APP_NAME}"
SYSTEMD_USER_DIR="${XDG_CONFIG_HOME:-${HOME}/.config}/systemd/user"
mkdir -p "$BIN_DIR" "$CONFIG_DIR" "$STATE_DIR/logs" "$SYSTEMD_USER_DIR"
install -m 0750 "$ROOT_DIR/scripts/nextcloud-borg-backup" "$BIN_DIR/nextcloud-borg-backup"
if [[ ! -f "$CONFIG_DIR/config" ]]; then
install -m 0600 "$ROOT_DIR/etc/config.example" "$CONFIG_DIR/config"
printf 'Installed config: %s\n' "$CONFIG_DIR/config"
else
printf 'Config exists, not overwriting: %s\n' "$CONFIG_DIR/config"
fi
if [[ ! -f "$CONFIG_DIR/excludes" ]]; then
install -m 0600 "$ROOT_DIR/etc/excludes.example" "$CONFIG_DIR/excludes"
fi
if [[ ! -f "$CONFIG_DIR/passphrase" ]]; then
if command -v openssl >/dev/null 2>&1; then
openssl rand -base64 48 > "$CONFIG_DIR/passphrase"
else
od -An -tx1 -N48 /dev/urandom | tr -d ' \n' > "$CONFIG_DIR/passphrase"
printf '\n' >> "$CONFIG_DIR/passphrase"
fi
chmod 0600 "$CONFIG_DIR/passphrase"
printf 'Created Borg passphrase file: %s\n' "$CONFIG_DIR/passphrase"
printf 'Store a copy of this passphrase somewhere safe for disaster recovery.\n'
fi
install -m 0644 "$ROOT_DIR/systemd/user/nextcloud-borg-backup.service" "$SYSTEMD_USER_DIR/nextcloud-borg-backup.service"
install -m 0644 "$ROOT_DIR/systemd/user/nextcloud-borg-backup.timer" "$SYSTEMD_USER_DIR/nextcloud-borg-backup.timer"
systemctl --user daemon-reload
systemctl --user enable --now nextcloud-borg-backup.timer
printf '\nInstalled %s.\n' "$APP_NAME"
printf 'Next steps:\n'
printf ' 1. Check config: %s\n' "$CONFIG_DIR/config"
printf ' 2. Run first backup: systemctl --user start nextcloud-borg-backup.service\n'
printf ' 3. Watch logs: journalctl --user -u nextcloud-borg-backup.service -f\n'
+438
View File
@@ -0,0 +1,438 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
APP_NAME="nextcloud-borg-backup"
APP_VERSION="1.0.0"
CONFIG_FILE="${NCB_CONFIG:-${XDG_CONFIG_HOME:-${HOME}/.config}/${APP_NAME}/config}"
die() {
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
warn() {
printf 'WARN: %s\n' "$*" >&2
}
info() {
printf '%s\n' "$*"
}
have() {
command -v "$1" >/dev/null 2>&1
}
shell_quote() {
printf '%q' "$1"
}
usage() {
cat <<'EOF'
nextcloud-borg-backup - Borg backup helper for a mounted Nextcloud folder
Usage:
nextcloud-borg-backup backup
nextcloud-borg-backup init
nextcloud-borg-backup list
nextcloud-borg-backup info [ARCHIVE]
nextcloud-borg-backup search PATTERN [ARCHIVE]
nextcloud-borg-backup mount [ARCHIVE] [MOUNT_DIR]
nextcloud-borg-backup umount [MOUNT_DIR]
nextcloud-borg-backup restore [ARCHIVE] [PATH] [DEST_DIR]
nextcloud-borg-backup check [--verify-data]
nextcloud-borg-backup prune
nextcloud-borg-backup status
nextcloud-borg-backup break-lock
Environment:
NCB_CONFIG=/path/to/config
EOF
}
load_config() {
[[ -r "$CONFIG_FILE" ]] || die "Config not readable: $CONFIG_FILE"
# shellcheck disable=SC1090
source "$CONFIG_FILE"
: "${SOURCE_DIR:=${HOME}/Nextcloud}"
: "${REPO_DIR:=/net/thor/volume1/NetBackup/Nextcloud/borg-repo}"
: "${ARCHIVE_PREFIX:=nextcloud}"
: "${BORG_BIN:=borg}"
: "${BORG_ENCRYPTION:=repokey-blake2}"
: "${BORG_PASSPHRASE_FILE:=${XDG_CONFIG_HOME:-${HOME}/.config}/${APP_NAME}/passphrase}"
: "${BORG_COMPRESSION:=zstd,6}"
: "${BORG_REMOTE_PATH:=borg}"
: "${BORG_FILES_CACHE:=}"
: "${RETENTION_WITHIN:=4w}"
: "${PRUNE_AFTER_BACKUP:=true}"
: "${COMPACT_AFTER_PRUNE:=true}"
: "${REPOSITORY_CHECK_AFTER_BACKUP:=true}"
: "${FULL_CHECK_INTERVAL_DAYS:=7}"
: "${FULL_CHECK_VERIFY_DATA:=false}"
: "${ALLOW_EMPTY_SOURCE:=false}"
: "${MIN_FREE_REPO_GB:=5}"
: "${PRECHECK_TIMEOUT_SECONDS:=60}"
: "${LOG_DIR:=${XDG_STATE_HOME:-${HOME}/.local/state}/${APP_NAME}/logs}"
: "${STATE_DIR:=${XDG_STATE_HOME:-${HOME}/.local/state}/${APP_NAME}}"
: "${RESTORE_DIR:=${HOME}/Nextcloud-Restore}"
: "${MOUNT_DIR:=${XDG_RUNTIME_DIR:-/tmp}/${APP_NAME}-mount}"
: "${EXCLUDE_FILE:=${XDG_CONFIG_HOME:-${HOME}/.config}/${APP_NAME}/excludes}"
: "${EXCLUDE_CACHES:=true}"
: "${ONE_FILE_SYSTEM:=true}"
: "${LOCK_FILE:=${STATE_DIR}/${APP_NAME}.lock}"
export BORG_RELOCATED_REPO_ACCESS_IS_OK="${BORG_RELOCATED_REPO_ACCESS_IS_OK:-yes}"
export BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK="${BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK:-yes}"
export BORG_REMOTE_PATH
[[ -n "$BORG_FILES_CACHE" ]] && export BORG_FILES_CACHE
if [[ -f "$BORG_PASSPHRASE_FILE" ]]; then
export BORG_PASSCOMMAND="cat $(shell_quote "$BORG_PASSPHRASE_FILE")"
fi
}
require_command() {
have "$1" || die "Required command missing: $1"
}
ensure_dirs() {
mkdir -p "$LOG_DIR" "$STATE_DIR"
}
log_file_for_run() {
local timestamp
timestamp="$(date '+%Y-%m-%d_%H-%M-%S')"
printf '%s/%s_%s.log' "$LOG_DIR" "$APP_NAME" "$timestamp"
}
start_logging() {
ensure_dirs
local log_file
log_file="$(log_file_for_run)"
exec > >(tee -a "$log_file") 2>&1
info "== ${APP_NAME} ${APP_VERSION} =="
info "-- Time: $(date -Is)"
info "-- Config: $CONFIG_FILE"
info "-- Log: $log_file"
}
with_lock() {
exec 9>"$LOCK_FILE"
flock -n 9 || die "Another ${APP_NAME} run is already active: $LOCK_FILE"
}
repo_parent() {
dirname "$REPO_DIR"
}
check_passphrase_for_encryption() {
case "$BORG_ENCRYPTION" in
none|authenticated*) return 0 ;;
esac
[[ -r "$BORG_PASSPHRASE_FILE" ]] || die "BORG_PASSPHRASE_FILE missing/read-protected: $BORG_PASSPHRASE_FILE"
}
borg_repo_exists() {
[[ -d "$REPO_DIR" && -f "$REPO_DIR/config" ]]
}
init_repo() {
require_command "$BORG_BIN"
check_passphrase_for_encryption
mkdir -p "$(repo_parent)"
if borg_repo_exists; then
info "-- Borg repository already exists: $REPO_DIR"
return 0
fi
info "-- Initializing Borg repository: $REPO_DIR"
"$BORG_BIN" init --encryption="$BORG_ENCRYPTION" "$REPO_DIR"
}
timeout_cmd() {
if have timeout; then
timeout "$PRECHECK_TIMEOUT_SECONDS" "$@"
else
"$@"
fi
}
count_source_entries() {
timeout_cmd find "$SOURCE_DIR" -mindepth 1 -maxdepth 1 -print -quit | wc -l
}
free_repo_gb() {
df -PBG "$(repo_parent)" | awk 'NR==2 { gsub(/G/, "", $4); print $4 }'
}
preflight() {
require_command "$BORG_BIN"
require_command flock
require_command df
require_command find
info "-- Source: $SOURCE_DIR"
info "-- Repository: $REPO_DIR"
timeout_cmd test -d "$SOURCE_DIR" || die "Source directory is not reachable: $SOURCE_DIR"
timeout_cmd test -r "$SOURCE_DIR" || die "Source directory is not readable: $SOURCE_DIR"
local entry_count
entry_count="$(count_source_entries | tr -d '[:space:]')"
if [[ "$ALLOW_EMPTY_SOURCE" != "true" && "${entry_count:-0}" -eq 0 ]]; then
die "Source directory looks empty. Refusing to create an empty backup."
fi
mkdir -p "$(repo_parent)"
timeout_cmd test -w "$(repo_parent)" || die "Repository parent is not writable/reachable: $(repo_parent)"
local free_gb
free_gb="$(free_repo_gb)"
info "-- Free space at repository parent: ${free_gb}G"
[[ "${free_gb:-0}" -ge "$MIN_FREE_REPO_GB" ]] || die "Not enough free space at repository parent; need ${MIN_FREE_REPO_GB}G"
if ! borg_repo_exists; then
init_repo
fi
}
create_archive() {
local timestamp archive
timestamp="$(date '+%Y-%m-%d_%H-%M-%S')"
archive="${ARCHIVE_PREFIX}-${timestamp}"
local borg_args
borg_args=(
create
--stats
--show-rc
--compression "$BORG_COMPRESSION"
)
[[ "$EXCLUDE_CACHES" == "true" ]] && borg_args+=(--exclude-caches)
[[ "$ONE_FILE_SYSTEM" == "true" ]] && borg_args+=(--one-file-system)
[[ -f "$EXCLUDE_FILE" ]] && borg_args+=(--exclude-from "$EXCLUDE_FILE")
info "-- Creating archive: $archive"
(
cd "$SOURCE_DIR"
"$BORG_BIN" "${borg_args[@]}" "${REPO_DIR}::${archive}" .
)
}
archive_names() {
"$BORG_BIN" list --short --glob-archives "${ARCHIVE_PREFIX}-*" "$REPO_DIR"
}
latest_archive() {
archive_names | tail -n 1
}
prune_repo() {
info "-- Pruning archives with retention: keep-within $RETENTION_WITHIN"
"$BORG_BIN" prune --list --show-rc --glob-archives "${ARCHIVE_PREFIX}-*" --keep-within "$RETENTION_WITHIN" "$REPO_DIR"
}
compact_repo() {
info "-- Compacting repository"
"$BORG_BIN" compact "$REPO_DIR"
}
days_since_stamp() {
local stamp="$1"
[[ -f "$stamp" ]] || { printf '999999\n'; return 0; }
local now then
now="$(date +%s)"
then="$(stat -c %Y "$stamp")"
printf '%s\n' "$(((now - then) / 86400))"
}
check_repo() {
local verify_data="${1:-false}"
if [[ "$verify_data" == "true" ]]; then
info "-- Running full Borg check with data verification"
"$BORG_BIN" check --verify-data "$REPO_DIR"
else
info "-- Running Borg repository check"
"$BORG_BIN" check --repository-only "$REPO_DIR"
fi
}
maybe_periodic_full_check() {
[[ "$FULL_CHECK_VERIFY_DATA" == "true" ]] || return 0
local stamp="${STATE_DIR}/last-full-check"
local age_days
age_days="$(days_since_stamp "$stamp")"
[[ "$age_days" -ge "$FULL_CHECK_INTERVAL_DAYS" ]] || return 0
check_repo true
date -Is > "$stamp"
}
cmd_backup() {
load_config
start_logging
with_lock
preflight
create_archive
if [[ "$PRUNE_AFTER_BACKUP" == "true" ]]; then
prune_repo
[[ "$COMPACT_AFTER_PRUNE" == "true" ]] && compact_repo
fi
[[ "$REPOSITORY_CHECK_AFTER_BACKUP" == "true" ]] && check_repo false
maybe_periodic_full_check
info "-- Backup finished successfully"
}
cmd_list() {
load_config
require_command "$BORG_BIN"
"$BORG_BIN" list "$REPO_DIR"
}
cmd_info() {
load_config
require_command "$BORG_BIN"
local archive="${1:-}"
[[ -n "$archive" ]] || archive="$(latest_archive)"
[[ -n "$archive" ]] || die "No archive found"
"$BORG_BIN" info "${REPO_DIR}::${archive}"
}
cmd_search() {
load_config
require_command "$BORG_BIN"
local pattern="${1:-}"
local archive="${2:-}"
[[ -n "$pattern" ]] || die "Missing search pattern"
if [[ -n "$archive" ]]; then
"$BORG_BIN" list --short "${REPO_DIR}::${archive}" | grep -i -- "$pattern" || true
return 0
fi
local found=0
while IFS= read -r archive; do
[[ -n "$archive" ]] || continue
while IFS= read -r path; do
printf '%s\t%s\n' "$archive" "$path"
found=1
done < <("$BORG_BIN" list --short "${REPO_DIR}::${archive}" | grep -i -- "$pattern" || true)
done < <(archive_names)
[[ "$found" -eq 1 ]] || warn "No matches found for: $pattern"
}
cmd_mount() {
load_config
require_command "$BORG_BIN"
local archive="${1:-}"
local mount_dir="${2:-$MOUNT_DIR}"
mkdir -p "$mount_dir"
if [[ -n "$archive" ]]; then
info "-- Mounting archive $archive at $mount_dir"
"$BORG_BIN" mount "${REPO_DIR}::${archive}" "$mount_dir"
else
info "-- Mounting repository archive overview at $mount_dir"
"$BORG_BIN" mount "$REPO_DIR" "$mount_dir"
fi
info "-- Mounted at: $mount_dir"
}
cmd_umount() {
load_config
local mount_dir="${1:-$MOUNT_DIR}"
if have borg; then
"$BORG_BIN" umount "$mount_dir" 2>/dev/null && return 0
fi
if have fusermount3; then
fusermount3 -u "$mount_dir"
elif have fusermount; then
fusermount -u "$mount_dir"
else
umount "$mount_dir"
fi
}
normalize_restore_path() {
local path="$1"
path="${path#/}"
path="${path#./}"
printf '%s\n' "$path"
}
cmd_restore() {
load_config
require_command "$BORG_BIN"
local archive="${1:-}"
local restore_path="${2:-}"
local dest_dir="${3:-}"
[[ -n "$archive" ]] || archive="$(latest_archive)"
[[ -n "$archive" ]] || die "No archive found"
[[ -n "$dest_dir" ]] || dest_dir="${RESTORE_DIR}/${archive}"
mkdir -p "$dest_dir"
info "-- Restoring from archive: $archive"
info "-- Destination: $dest_dir"
(
cd "$dest_dir"
if [[ -n "$restore_path" ]]; then
restore_path="$(normalize_restore_path "$restore_path")"
"$BORG_BIN" extract "${REPO_DIR}::${archive}" "$restore_path"
else
"$BORG_BIN" extract "${REPO_DIR}::${archive}"
fi
)
info "-- Restore finished: $dest_dir"
}
cmd_check() {
load_config
require_command "$BORG_BIN"
local verify_data=false
[[ "${1:-}" == "--verify-data" ]] && verify_data=true
check_repo "$verify_data"
}
cmd_status() {
load_config
require_command "$BORG_BIN"
info "Config: $CONFIG_FILE"
info "Source: $SOURCE_DIR"
info "Repository: $REPO_DIR"
info "Retention: $RETENTION_WITHIN"
info "Latest archive: $(latest_archive || true)"
info
"$BORG_BIN" info "$REPO_DIR"
}
cmd_break_lock() {
load_config
require_command "$BORG_BIN"
"$BORG_BIN" break-lock "$REPO_DIR"
}
main() {
local cmd="${1:-}"
[[ $# -gt 0 ]] && shift || true
case "$cmd" in
backup) cmd_backup "$@" ;;
init) load_config; init_repo "$@" ;;
list) cmd_list "$@" ;;
info) cmd_info "$@" ;;
search) cmd_search "$@" ;;
mount) cmd_mount "$@" ;;
umount|unmount) cmd_umount "$@" ;;
restore) cmd_restore "$@" ;;
check) cmd_check "$@" ;;
prune) load_config; prune_repo "$@"; compact_repo ;;
status) cmd_status "$@" ;;
break-lock|unlock) cmd_break_lock "$@" ;;
-h|--help|help|"") usage ;;
--version|version) printf '%s %s\n' "$APP_NAME" "$APP_VERSION" ;;
*) usage >&2; die "Unknown command: $cmd" ;;
esac
}
main "$@"
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -Eeuo pipefail
APP_NAME="nextcloud-borg-backup"
BIN_DIR="${HOME}/.local/bin"
SYSTEMD_USER_DIR="${XDG_CONFIG_HOME:-${HOME}/.config}/systemd/user"
systemctl --user disable --now "${APP_NAME}.timer" 2>/dev/null || true
systemctl --user daemon-reload
rm -f "${SYSTEMD_USER_DIR}/${APP_NAME}.service" "${SYSTEMD_USER_DIR}/${APP_NAME}.timer"
rm -f "${BIN_DIR}/${APP_NAME}"
systemctl --user daemon-reload
printf 'Removed user service, timer, and executable.\n'
printf 'Kept config, passphrase, logs, restore directory, and Borg repository.\n'