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
+11
View File
@@ -0,0 +1,11 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
+4
View File
@@ -0,0 +1,4 @@
.DS_Store
*.log
/.cache/
/tmp/
+283
View File
@@ -0,0 +1,283 @@
# Dokumentation: nextcloud-borg-backup
## Ziel
Diese Anwendung sichert den via rclone gemounteten Ordner `/home/johannes/Nextcloud` regelmäßig auf den via autofs erreichbaren Synology-Pfad `/net/thor/volume1/NetBackup/Nextcloud`.
Die Sicherung verwendet Borg, weil Borg inkrementelle, deduplizierte und verschlüsselte Archive erstellt. Dadurch bleiben tägliche Backups platzsparend und einzelne Dateien können aus älteren Ständen wiederhergestellt werden.
## Architektur
Bestandteile:
- `scripts/nextcloud-borg-backup`: Haupt-CLI für Backup, Restore, Suche, Mount, Check und Retention.
- `etc/config.example`: Beispielkonfiguration mit den produktiven Standardpfaden.
- `systemd/user/nextcloud-borg-backup.service`: systemd User Service für einen Backup-Lauf.
- `systemd/user/nextcloud-borg-backup.timer`: täglicher Timer.
- `scripts/install.sh`: Installation in das Home-Verzeichnis des Users.
- `scripts/uninstall.sh`: Entfernt Service, Timer und Executable, lässt Daten bewusst bestehen.
Die Anwendung läuft als User `johannes`. Das ist für diesen Fall sinnvoll, weil die Quelle ein rclone/FUSE-Mount unterhalb des Home-Verzeichnisses ist. Ein root-Service könnte je nach FUSE-Konfiguration keine stabilen Leserechte auf diesen Mount haben.
## Pfade
Standardkonfiguration:
```bash
SOURCE_DIR="${HOME}/Nextcloud"
REPO_DIR="/net/thor/volume1/NetBackup/Nextcloud/borg-repo"
ARCHIVE_PREFIX="nextcloud"
RETENTION_WITHIN="4w"
```
Lokale Laufzeitdaten:
```bash
~/.config/nextcloud-borg-backup/config
~/.config/nextcloud-borg-backup/passphrase
~/.config/nextcloud-borg-backup/excludes
~/.local/state/nextcloud-borg-backup/logs/
~/Nextcloud-Restore/
```
## Installation
```bash
cd /home/johannes/localdev/nextcloud-borg-backup
./scripts/install.sh
```
Der Installer:
1. kopiert die CLI nach `~/.local/bin/nextcloud-borg-backup`,
2. legt die Konfiguration an, falls sie noch nicht existiert,
3. erzeugt eine Borg-Passphrase, falls noch keine existiert,
4. installiert systemd-User-Service und Timer,
5. aktiviert den Timer.
Für automatische Läufe ohne aktive Anmeldung:
```bash
sudo loginctl enable-linger johannes
```
## Erster Backup-Lauf
```bash
systemctl --user start nextcloud-borg-backup.service
```
Logs:
```bash
journalctl --user -u nextcloud-borg-backup.service -f
ls -lah ~/.local/state/nextcloud-borg-backup/logs/
```
Status:
```bash
nextcloud-borg-backup status
nextcloud-borg-backup list
```
## Zeitplanung
Der Timer läuft täglich:
```ini
OnCalendar=*-*-* 03:15:00
Persistent=true
RandomizedDelaySec=30m
```
`Persistent=true` sorgt dafür, dass ein verpasster Lauf nachgeholt wird, sobald der User-systemd-Manager wieder aktiv ist.
## Retention
Die Aufbewahrung ist auf vier Wochen ausgelegt:
```bash
RETENTION_WITHIN="4w"
```
Nach jedem Backup läuft:
```bash
borg prune --keep-within 4w
borg compact
borg check --repository-only
```
Damit bleiben alle Archive der letzten vier Wochen erhalten. Bei einem täglichen Timer sind dadurch mindestens tägliche Wiederherstellungspunkte über rund einen Monat verfügbar. Manuelle Zusatzläufe innerhalb der vier Wochen werden ebenfalls behalten.
## Resilienzmaßnahmen
Die Anwendung bricht bewusst ab, wenn eine Sicherheitsannahme nicht erfüllt ist:
- Quelle ist nicht erreichbar oder nicht lesbar.
- Quelle wirkt leer und `ALLOW_EMPTY_SOURCE=false`.
- Synology-Ziel ist nicht erreichbar oder nicht beschreibbar.
- Freier Platz am Ziel liegt unter `MIN_FREE_REPO_GB`.
- Borg-Passphrase fehlt bei verschlüsseltem Repository.
- Ein anderer Backup-Lauf hält bereits den Lock.
Weitere Maßnahmen:
- Borg-Repo wird bei Bedarf einmalig initialisiert.
- Borg verwendet deduplizierte Archive statt Vollkopien.
- Nach dem Backup läuft Retention und Kompaktierung.
- Nach dem Backup läuft ein Repository-Check.
- Optional kann regelmäßig ein vollständiger Datencheck aktiviert werden:
```bash
FULL_CHECK_VERIFY_DATA="true"
FULL_CHECK_INTERVAL_DAYS="7"
```
Ein vollständiger `borg check --verify-data` kann bei großen Repositories lange dauern.
## Dateien suchen
Über alle Archive suchen:
```bash
nextcloud-borg-backup search "Steuer"
```
Nur in einem bestimmten Archiv suchen:
```bash
nextcloud-borg-backup search "Steuer" nextcloud-2026-07-10_03-15-00
```
Die Ausgabe enthält bei archivübergreifender Suche den Archivnamen und den Pfad.
## Archive mounten
Alle Archive als lesbare Struktur mounten:
```bash
nextcloud-borg-backup mount
```
Standard-Mountpunkt:
```bash
${XDG_RUNTIME_DIR}/nextcloud-borg-backup-mount
```
Ein einzelnes Archiv mounten:
```bash
nextcloud-borg-backup mount nextcloud-2026-07-10_03-15-00
```
Unmount:
```bash
nextcloud-borg-backup umount
```
Falls `borg mount` nicht verfügbar ist, muss auf Arch Linux das passende FUSE-Paket installiert sein.
## Restore
Neuestes Archiv komplett wiederherstellen:
```bash
nextcloud-borg-backup restore
```
Ziel:
```bash
~/Nextcloud-Restore/<Archivname>/
```
Bestimmten Pfad aus dem neuesten Archiv wiederherstellen:
```bash
nextcloud-borg-backup restore "" "Dokumente/Projekt"
```
Bestimmten Pfad aus einem bestimmten Archiv wiederherstellen:
```bash
nextcloud-borg-backup restore nextcloud-2026-07-10_03-15-00 "Dokumente/Projekt" ~/Restore-Test
```
Wichtig: Die Anwendung extrahiert standardmäßig nicht direkt zurück in den produktiven Nextcloud-Ordner. Das reduziert das Risiko, versehentlich aktuelle Dateien zu überschreiben. Nach Prüfung können die wiederhergestellten Dateien gezielt zurückkopiert werden.
## Disaster Recovery
Für eine Wiederherstellung auf einem neuen System werden benötigt:
1. das Borg-Repository unter `/net/thor/volume1/NetBackup/Nextcloud/borg-repo`,
2. die Borg-Passphrase aus `~/.config/nextcloud-borg-backup/passphrase`,
3. Borg selbst.
Minimaler Restore:
```bash
export BORG_PASSCOMMAND='cat /pfad/zur/passphrase'
borg list /net/thor/volume1/NetBackup/Nextcloud/borg-repo
borg extract /net/thor/volume1/NetBackup/Nextcloud/borg-repo::ARCHIVNAME
```
Die Passphrase sollte separat sicher abgelegt werden. Ohne Passphrase ist ein verschlüsseltes Repository nicht nutzbar.
## Wartung
Manueller Repository-Check:
```bash
nextcloud-borg-backup check
```
Vollständiger Datencheck:
```bash
nextcloud-borg-backup check --verify-data
```
Borg-Lock lösen, falls ein Lauf hart abgebrochen wurde und sicher kein Backup mehr läuft:
```bash
nextcloud-borg-backup break-lock
```
Timer deaktivieren:
```bash
systemctl --user disable --now nextcloud-borg-backup.timer
```
Deinstallation der lokalen Anwendung:
```bash
./scripts/uninstall.sh
```
Die Deinstallation löscht bewusst nicht:
- Borg-Repository,
- Passphrase,
- Konfiguration,
- Logs,
- Restore-Verzeichnis.
## Gitea-Upload
Das Verzeichnis `/home/johannes/localdev/nextcloud-borg-backup` ist als eigenständiges Projekt vorbereitet. Vor dem Push:
```bash
cd /home/johannes/localdev/nextcloud-borg-backup
git init
git add .
git commit -m "Initial nextcloud borg backup application"
git remote add origin <gitea-url>
git push -u origin main
```
Keine lokalen Geheimnisse werden im Projekt abgelegt. Die produktive Passphrase entsteht erst bei der Installation unter `~/.config/nextcloud-borg-backup/passphrase`.
+11
View File
@@ -0,0 +1,11 @@
.PHONY: check install uninstall
check:
bash -n scripts/nextcloud-borg-backup scripts/install.sh scripts/uninstall.sh
systemd-analyze --user verify systemd/user/nextcloud-borg-backup.service systemd/user/nextcloud-borg-backup.timer
install:
./scripts/install.sh
uninstall:
./scripts/uninstall.sh
+94
View File
@@ -0,0 +1,94 @@
# nextcloud-borg-backup
Tägliches Borg-Backup des lokalen Nextcloud-Mounts nach Synology:
- Quelle: `/home/johannes/Nextcloud`
- Ziel: `/net/thor/volume1/NetBackup/Nextcloud/borg-repo`
- Historie: `4w`, also vier Wochen wiederherstellbare Archive
- Betrieb: systemd User Timer, täglich um 03:15 Uhr mit Zufallsverzögerung
- Restore: über CLI, Suche, Borg-Mount oder gezieltes Extrahieren
Das Projekt ist bewusst klein gehalten: eine Bash-CLI, Konfiguration, systemd-User-Units und Dokumentation. Es eignet sich direkt für ein Gitea-Repository.
## Installation
```bash
cd /home/johannes/localdev/nextcloud-borg-backup
./scripts/install.sh
```
Der Installer legt an:
- `~/.local/bin/nextcloud-borg-backup`
- `~/.config/nextcloud-borg-backup/config`
- `~/.config/nextcloud-borg-backup/passphrase`
- `~/.config/systemd/user/nextcloud-borg-backup.service`
- `~/.config/systemd/user/nextcloud-borg-backup.timer`
Der Installer überschreibt eine vorhandene Konfiguration nicht.
Danach den ersten Lauf manuell starten:
```bash
systemctl --user start nextcloud-borg-backup.service
journalctl --user -u nextcloud-borg-backup.service -f
```
Timer prüfen:
```bash
systemctl --user status nextcloud-borg-backup.timer
systemctl --user list-timers nextcloud-borg-backup.timer
```
Damit der User-Timer auch ohne aktive Anmeldung läuft:
```bash
sudo loginctl enable-linger johannes
```
## Bedienung
```bash
nextcloud-borg-backup backup
nextcloud-borg-backup list
nextcloud-borg-backup status
nextcloud-borg-backup search Rechnung
nextcloud-borg-backup mount
nextcloud-borg-backup restore
nextcloud-borg-backup check --verify-data
```
## Restore-Beispiele
Neuestes Archiv komplett nach `~/Nextcloud-Restore/<Archivname>` wiederherstellen:
```bash
nextcloud-borg-backup restore
```
Bestimmte Datei im neuesten Archiv wiederherstellen:
```bash
nextcloud-borg-backup restore "" "Dokumente/Beispiel.pdf"
```
Bestimmtes Archiv und bestimmten Pfad wiederherstellen:
```bash
nextcloud-borg-backup restore nextcloud-2026-07-10_03-15-00 "Dokumente"
```
Archive bequem lesbar mounten:
```bash
nextcloud-borg-backup mount
xdg-open "${XDG_RUNTIME_DIR}/nextcloud-borg-backup-mount"
nextcloud-borg-backup umount
```
## Wichtige Hinweise
Die Borg-Passphrase liegt lokal in `~/.config/nextcloud-borg-backup/passphrase`. Ohne diese Datei kann ein verschlüsseltes Borg-Repository im Notfall nicht wiederhergestellt werden. Eine Kopie gehört daher separat und sicher abgelegt.
Weitere Details stehen in [Dokumentation.md](Dokumentation.md).
+44
View File
@@ -0,0 +1,44 @@
# nextcloud-borg-backup configuration
#
# Copy to:
# ~/.config/nextcloud-borg-backup/config
SOURCE_DIR="${HOME}/Nextcloud"
REPO_DIR="/net/thor/volume1/NetBackup/Nextcloud/borg-repo"
ARCHIVE_PREFIX="nextcloud"
# Borg repository encryption. repokey-blake2 needs a passphrase file for
# unattended systemd timer runs. The installer creates this file if missing.
BORG_ENCRYPTION="repokey-blake2"
BORG_PASSPHRASE_FILE="${HOME}/.config/nextcloud-borg-backup/passphrase"
BORG_COMPRESSION="zstd,6"
# Four weeks of restorable history. With the daily timer this keeps at least
# one month of archives; manual extra runs inside the window are also retained.
RETENTION_WITHIN="4w"
# Safety checks.
ALLOW_EMPTY_SOURCE="false"
MIN_FREE_REPO_GB="5"
PRECHECK_TIMEOUT_SECONDS="60"
# Runtime paths.
LOG_DIR="${HOME}/.local/state/nextcloud-borg-backup/logs"
STATE_DIR="${HOME}/.local/state/nextcloud-borg-backup"
RESTORE_DIR="${HOME}/Nextcloud-Restore"
MOUNT_DIR="${XDG_RUNTIME_DIR:-/tmp}/nextcloud-borg-backup-mount"
EXCLUDE_FILE="${HOME}/.config/nextcloud-borg-backup/excludes"
LOCK_FILE="${HOME}/.local/state/nextcloud-borg-backup/nextcloud-borg-backup.lock"
# Borg behavior.
PRUNE_AFTER_BACKUP="true"
COMPACT_AFTER_PRUNE="true"
REPOSITORY_CHECK_AFTER_BACKUP="true"
FULL_CHECK_INTERVAL_DAYS="7"
FULL_CHECK_VERIFY_DATA="false"
EXCLUDE_CACHES="true"
ONE_FILE_SYSTEM="true"
# Leave empty unless the rclone mount exposes unstable metadata and Borg rereads
# too much. Conservative alternative: BORG_FILES_CACHE="mtime,size"
BORG_FILES_CACHE=""
+9
View File
@@ -0,0 +1,9 @@
# Optional Borg exclude patterns, one per line.
# The backup runs from inside SOURCE_DIR, so patterns are relative.
#
# Examples:
# cache directories:
# */.cache
#
# thumbnails:
# */thumbnails
+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'
@@ -0,0 +1,13 @@
[Unit]
Description=Back up mounted Nextcloud folder to Synology with Borg
Documentation=man:borg(1)
[Service]
Type=oneshot
ExecStart=%h/.local/bin/nextcloud-borg-backup backup
TimeoutStartSec=12h
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=6
Environment=LANG=C.UTF-8
Environment=LC_ALL=C.UTF-8
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Daily Nextcloud Borg backup timer
[Timer]
OnCalendar=*-*-* 03:15:00
Persistent=true
RandomizedDelaySec=30m
Unit=nextcloud-borg-backup.service
[Install]
WantedBy=timers.target