#!/bin/sh # # borg-apiscp-repo: manage the Borg repository the engine writes to. # # Usage: # borg-apiscp-repo status # Show repository info (borg info). # # borg-apiscp-repo init # Create (initialise) the repository at BORG_REPO with BORG_ENCRYPTION # (default repokey-blake2). Needs a passphrase (see gen-passphrase). Safe # to run once; refuses if the repository already exists. # # borg-apiscp-repo check-access # Confirm the repository is reachable and the passphrase is correct # (prints "ok" or an error); always exits 0 so a UI can read the message. # # borg-apiscp-repo gen-passphrase # Generate a strong random passphrase, store it as BORG_PASSPHRASE, and # print ONLY the passphrase to stdout (so a UI can show it once). # # borg-apiscp-repo key-status # Print "yes" if the one-time key/passphrase backup has been done, else "no". # # borg-apiscp-repo backup-key [DEST] [--force] # Export the passphrase and `borg key export` to DEST (default # KEY_BACKUP_DIR), 0600, once. Store DEST off this machine. # # borg-apiscp-repo key-bundle # Print a self-contained recovery bundle (instructions + passphrase + # exported key + repo location) to stdout for the one-time GUI display. # Contains secrets by design. # # borg-apiscp-repo config-get | config-set KEY VALUE # Read / write whitelisted keys in the config file. # # borg-apiscp-repo sites # List "siteNdomain" for every ApisCP site. # # borg-apiscp-repo run [--site SITE | --path PATH] # No args: start the scheduled backup service (detached). --site/--path: # a detached one-shot scoped to one site or one extra absolute path. # # borg-apiscp-repo running # Print the backup service state (active/inactive/...). # # borg-apiscp-repo prune # Apply retention (borg prune) across all archive prefixes per config. # # borg-apiscp-repo maintenance # Run `borg compact` (reclaim space), detached. # # borg-apiscp-repo verify [--data] # Run `borg check` (repository + archive consistency), detached. --data # also reads file contents back (slow, thorough). # # borg-apiscp-repo set-schedule [SPEC] # Set the backup timer's OnCalendar (HH:MM daily or an OnCalendar # expression); falls back to BACKUP_SCHEDULE. Writes a timer drop-in. # # borg-apiscp-repo notify-test # Send a test notification email to NOTIFY_EMAIL. # # borg-apiscp-repo list-databases SITE # List "\t" for every database ApisCP knows for SITE. set -u _self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) for _cand in \ "${APISCP_BORG_LIB:-}" \ "$_self_dir/../lib/apiscp-borg-common.sh" \ /usr/local/lib/apiscp-borg/apiscp-borg-common.sh \ /usr/lib/apiscp-borg/apiscp-borg-common.sh; do [ -n "$_cand" ] && [ -r "$_cand" ] && { . "$_cand"; _lib_loaded=1; break; } done [ "${_lib_loaded:-0}" = 1 ] || { echo "FATAL: cannot find apiscp-borg-common.sh" >&2; exit 1; } CONFIG_FILE="${APISCP_BORG_CONFIG:-/etc/apiscp-borg/config}" # shellcheck source=/dev/null [ -r "$CONFIG_FILE" ] && . "$CONFIG_FILE" : "${BORG_REPO:=}" : "${BORG_ENCRYPTION:=repokey-blake2}" : "${BORG_RSH:=ssh -o BatchMode=yes}" : "${KEY_BACKUP_DIR:=/root/apiscp-borg-keys}" : "${VIRTBASE:=/home/virtual}" : "${APNSCP_CMD:=/usr/local/apnscp/bin/cmd}" export BORG_REPO BORG_RSH [ -n "${BORG_PASSPHRASE:-}" ] && export BORG_PASSPHRASE usage() { awk 'NR>=2 && /^set -u/{exit} NR>=2{sub(/^# ?/,""); print}' "$0"; exit "${1:-2}"; } # Config keys the GUI/CLI may set. This list is the security boundary. CONFIG_ALLOWED="BORG_REPO BORG_PASSPHRASE BORG_ENCRYPTION BORG_RSH BORG_COMPRESSION KEY_BACKUP_DIR VIRTBASE METRICS_DIR RUN_DB_EXPORT BACKUP_SITES BACKUP_SYSTEM BACKUP_PATHS BACKUP_DATABASES BACKUP_EXCLUDES BACKUP_SCHEDULE CAPTURE_META_SIDECAR RETENTION_KEEP_WITHIN RETENTION_KEEP_DAILY RETENTION_KEEP_WEEKLY RETENTION_KEEP_MONTHLY RETENTION_KEEP_ANNUAL NOTIFY_EMAIL NOTIFY_ON NOTIFY_FROM" _need_repo() { [ -n "$BORG_REPO" ] || akp_die "BORG_REPO is not set (config-set BORG_REPO ... first)"; } cmd_config_get() { [ -r "$CONFIG_FILE" ] || return 0 while IFS= read -r _line; do case "$_line" in ''|\#*) continue ;; esac case "$_line" in *=*) ;; *) continue ;; esac _k=$(printf '%s' "${_line%%=*}" | tr -d '[:space:]') _v=${_line#*=} _v=$(printf '%s' "$_v" | sed -e "s/^'//" -e "s/'\$//" -e 's/^"//' -e 's/"$//') printf '%s=%s\n' "$_k" "$_v" done < "$CONFIG_FILE" } cmd_config_set() { _k="${1:-}"; _v="${2:-}" [ -n "$_k" ] || akp_die "config-set: key required" case " $CONFIG_ALLOWED " in *" $_k "*) ;; *) akp_die "config-set: refusing unknown key '$_k'" ;; esac if [ "$_v" != "$(printf '%s' "$_v" | tr -d '\r\n')" ]; then akp_die "config-set: value may not contain newlines" fi _esc=$(printf '%s' "$_v" | sed "s/'/'\\\\''/g") mkdir -p "$(dirname "$CONFIG_FILE")" _tmp=$(mktemp); _found=0 if [ -f "$CONFIG_FILE" ]; then while IFS= read -r _line; do case "$_line" in "$_k="*|"$_k ="*) printf "%s='%s'\n" "$_k" "$_esc" >> "$_tmp"; _found=1 ;; *) printf '%s\n' "$_line" >> "$_tmp" ;; esac done < "$CONFIG_FILE" fi [ "$_found" = 1 ] || printf "%s='%s'\n" "$_k" "$_esc" >> "$_tmp" install -m 0600 "$_tmp" "$CONFIG_FILE"; rm -f "$_tmp" } cmd_sites() { for _d in "$VIRTBASE"/site[0-9]*; do [ -d "$_d" ] || continue _s=$(basename "$_d") _dom=$(akp_site_domain "$_s") printf '%s\t%s\n' "$_s" "${_dom:-$_s}" done } cmd_status() { _need_repo; echo "repository: $BORG_REPO"; akp_borg info 2>&1 | sed 's/^/ /' | head -30; } cmd_check_access() { _need_repo if akp_borg info >/dev/null 2>&1; then echo ok; else echo "error: cannot access $BORG_REPO (check passphrase/connectivity)"; fi return 0 } cmd_init() { _need_repo [ -n "${BORG_PASSPHRASE:-}" ] || akp_die "no passphrase set; run: borg-apiscp-repo gen-passphrase first" if akp_borg info >/dev/null 2>&1; then akp_log "repository already exists at $BORG_REPO; nothing to do" return 0 fi akp_log "initialising repository at $BORG_REPO ($BORG_ENCRYPTION)" akp_borg init --encryption "$BORG_ENCRYPTION" || akp_die "init failed" akp_log "repository initialised. Now back up the key: borg-apiscp-repo backup-key" } cmd_gen_passphrase() { if command -v openssl >/dev/null 2>&1; then _pw=$(openssl rand -base64 30); else _pw=$(head -c 30 /dev/urandom | base64); fi _pw=$(printf '%s' "$_pw" | tr -d '\r\n') [ -n "$_pw" ] || akp_die "gen-passphrase: failed to generate" cmd_config_set BORG_PASSPHRASE "$_pw" printf '%s\n' "$_pw" } cmd_key_status() { [ -e "$KEY_BACKUP_DIR/.apiscp-borg-key-backed-up" ] && echo yes || echo no; } cmd_backup_key() { _dest="$KEY_BACKUP_DIR"; _force=0 for a in "$@"; do case "$a" in --force) _force=1 ;; -*) usage 2 ;; *) _dest="$a" ;; esac; done _need_repo _sentinel="$_dest/.apiscp-borg-key-backed-up" if [ -e "$_sentinel" ] && [ "$_force" != 1 ]; then akp_log "key already backed up at $_dest ($(cat "$_sentinel")). Use --force to redo."; return 0 fi mkdir -p "$_dest" && chmod 0700 "$_dest" || akp_die "cannot create $_dest" if [ -n "${BORG_PASSPHRASE:-}" ]; then umask 077; printf '%s\n' "$BORG_PASSPHRASE" > "$_dest/repository.passphrase"; chmod 0600 "$_dest/repository.passphrase" else akp_warn "no BORG_PASSPHRASE in config; store the passphrase separately" fi akp_borg key export :: "$_dest/repository.key" 2>/dev/null || akp_warn "borg key export failed (repokey repos keep the key in the repo)" [ -f "$_dest/repository.key" ] && chmod 0600 "$_dest/repository.key" cat > "$_dest/README.txt" < 4. borg key import :: repository.key # only needed for keyfile-mode repos 5. borg list EOF chmod 0600 "$_dest/README.txt" date '+%Y-%m-%dT%H:%M:%S%z' > "$_sentinel" 2>/dev/null || echo done > "$_sentinel" akp_log "repository key backed up to $_dest" echo "IMPORTANT: copy $_dest to secure OFF-MACHINE storage now." >&2 } cmd_key_bundle() { _need_repo echo "apiscp-borg repository recovery bundle" echo "======================================" echo "KEEP THIS SAFE AND OFF THE SERVER. If this passphrase/key is lost, the" echo "encrypted backups are PERMANENTLY IRRECOVERABLE." echo echo "Repository: $BORG_REPO" echo echo "== passphrase ==" printf '%s\n' "${BORG_PASSPHRASE:-}" echo echo "== exported key (borg key import :: ; needed for keyfile-mode) ==" akp_borg key export :: /dev/stdout 2>/dev/null || echo "(key export unavailable)" } # run: no args starts the scheduled service; --site/--path a detached one-shot. cmd_run() { case "${1:-}" in ""|--all) systemctl start --no-block borg-apiscp-backup.service ;; --site) _s="${2:-}"; case "$_s" in site[0-9]*|none) ;; *) akp_die "run --site: invalid site '$_s'" ;; esac _run_oneshot BACKUP_SITES="$_s" BACKUP_SYSTEM=0 ;; --path) _p="${2:-}"; [ -n "$_p" ] || akp_die "run --path: path required" case "$_p" in /*) ;; *) akp_die "run --path: must be absolute" ;; esac { [ -e "$_p" ] && [ -r "$_p" ]; } || akp_die "run --path: not readable: $_p" _run_oneshot BACKUP_SITES=none BACKUP_SYSTEM=0 BACKUP_PATHS="$_p" ;; *) akp_die "run: unknown option '$1'" ;; esac } _run_oneshot() { _unit="apiscp-borg-manual-$(date +%s)" if command -v systemd-run >/dev/null 2>&1; then _sr="systemd-run --collect --unit=$_unit" for _kv in "$@"; do _sr="$_sr --setenv=$_kv"; done $_sr /usr/local/bin/borg-apiscp-backup else env "$@" /usr/local/bin/borg-apiscp-backup >/dev/null 2>&1 & akp_log "started detached backup (pid $!)" fi } cmd_running() { systemctl is-active borg-apiscp-backup.service 2>/dev/null || true; } # prune: apply retention across every archive prefix per config. cmd_prune() { _need_repo set -- [ -n "${RETENTION_KEEP_WITHIN:-}" ] && set -- "$@" --keep-within "$RETENTION_KEEP_WITHIN" [ -n "${RETENTION_KEEP_DAILY:-}" ] && set -- "$@" --keep-daily "$RETENTION_KEEP_DAILY" [ -n "${RETENTION_KEEP_WEEKLY:-}" ] && set -- "$@" --keep-weekly "$RETENTION_KEEP_WEEKLY" [ -n "${RETENTION_KEEP_MONTHLY:-}" ] && set -- "$@" --keep-monthly "$RETENTION_KEEP_MONTHLY" [ -n "${RETENTION_KEEP_ANNUAL:-}" ] && set -- "$@" --keep-yearly "$RETENTION_KEEP_ANNUAL" [ "$#" -ge 1 ] || { akp_log "prune: no retention configured; nothing to do"; return 0; } # Prune per prefix so one site's churn never expires another's history. for _d in "$VIRTBASE"/site[0-9]*; do [ -d "$_d" ] || continue _s=$(basename "$_d") for _sub in shadow info db; do akp_borg prune --glob-archives "$_s-$_sub-*" "$@" >/dev/null 2>&1 && akp_log "pruned $_s-$_sub-*" || akp_warn "prune failed for $_s-$_sub-*" done done akp_borg prune --glob-archives "_system-*" "$@" >/dev/null 2>&1 || true akp_borg prune --glob-archives "_custom-*" "$@" >/dev/null 2>&1 || true } cmd_maintenance() { _need_repo _unit="apiscp-borg-compact-$(date +%s)" if command -v systemd-run >/dev/null 2>&1; then systemd-run --collect --unit="$_unit" --setenv=BORG_REPO="$BORG_REPO" ${BORG_PASSPHRASE:+--setenv=BORG_PASSPHRASE="$BORG_PASSPHRASE"} --setenv=BORG_RSH="$BORG_RSH" borg compact akp_log "started borg compact ($_unit)" else akp_borg compact >/dev/null 2>&1 & akp_log "started borg compact (pid $!)" fi } cmd_verify() { _need_repo _data=""; [ "${1:-}" = "--data" ] && _data="--verify-data" _unit="apiscp-borg-check-$(date +%s)" if command -v systemd-run >/dev/null 2>&1; then systemd-run --collect --unit="$_unit" --setenv=BORG_REPO="$BORG_REPO" ${BORG_PASSPHRASE:+--setenv=BORG_PASSPHRASE="$BORG_PASSPHRASE"} --setenv=BORG_RSH="$BORG_RSH" borg check $_data akp_log "started borg check ${_data:-(consistency)} ($_unit)" else akp_borg check $_data >/dev/null 2>&1 & akp_log "started borg check (pid $!)" fi } cmd_set_schedule() { _spec="${1:-${BACKUP_SCHEDULE:-}}" [ -n "$_spec" ] || akp_die "set-schedule: no schedule given (HH:MM or an OnCalendar expression)" case "$_spec" in [0-9][0-9]:[0-9][0-9]) _h=${_spec%%:*}; _m=${_spec#*:} { [ "$_h" -ge 0 ] && [ "$_h" -le 23 ] && [ "$_m" -ge 0 ] && [ "$_m" -le 59 ]; } 2>/dev/null || akp_die "set-schedule: invalid time '$_spec'" _cal="*-*-* $_spec:00" ;; *) case "$_spec" in *[!0-9A-Za-z:*\ ,/-]*) akp_die "set-schedule: invalid schedule '$_spec'" ;; esac; _cal="$_spec" ;; esac _dir=/etc/systemd/system/borg-apiscp-backup.timer.d mkdir -p "$_dir"; _tmp=$(mktemp) printf '[Timer]\n# Managed by apiscp-borg (panel / BACKUP_SCHEDULE). Do not edit by hand.\nOnCalendar=\nOnCalendar=%s\n' "$_cal" > "$_tmp" install -m 0644 "$_tmp" "$_dir/override.conf"; rm -f "$_tmp" systemctl daemon-reload; systemctl restart borg-apiscp-backup.timer 2>/dev/null || true akp_log "backup schedule set to OnCalendar='$_cal'"; printf '%s\n' "$_cal" } cmd_notify_test() { [ -n "${NOTIFY_EMAIL:-}" ] || akp_die "notify-test: set NOTIFY_EMAIL first" _h=$(hostname 2>/dev/null || echo apiscp) akp_send_mail "$NOTIFY_EMAIL" "[apiscp-borg] test notification from $_h" \ "This is a test email from apiscp-borg on $_h. If you received it, backup notifications are configured correctly." \ "${NOTIFY_FROM:-}" || akp_die "notify-test: send failed (check the mail system)" akp_log "sent test notification to $NOTIFY_EMAIL"; printf 'sent to %s\n' "$NOTIFY_EMAIL" } cmd_list_databases() { _site="${1:-}" case "$_site" in site[0-9]*) ;; *) akp_die "list-databases: invalid site '$_site'" ;; esac _dom=$(akp_site_domain "$_site"); [ -n "$_dom" ] || akp_die "list-databases: no domain for $_site" for _eng in mysql pgsql; do _json=$("$APNSCP_CMD" -o json -d "$_dom" "$_eng:list_databases" 2>/dev/null) || continue [ -n "$_json" ] || continue printf '%s\n' "$_json" | grep -oE '"[A-Za-z0-9_-]+"' | sed 's/"//g' | while IFS= read -r _db; do [ -n "$_db" ] && printf '%s\t%s\n' "$_eng" "$_db" done done } case "${1:-}" in status) shift; cmd_status "$@" ;; init) shift; cmd_init "$@" ;; check-access) shift; cmd_check_access "$@" ;; gen-passphrase) shift; cmd_gen_passphrase "$@" ;; key-status) shift; cmd_key_status "$@" ;; backup-key) shift; cmd_backup_key "$@" ;; key-bundle) shift; cmd_key_bundle "$@" ;; config-get) shift; cmd_config_get "$@" ;; config-set) shift; cmd_config_set "$@" ;; sites) shift; cmd_sites "$@" ;; run) shift; cmd_run "$@" ;; running) shift; cmd_running "$@" ;; prune) shift; cmd_prune "$@" ;; maintenance) shift; cmd_maintenance "$@" ;; verify) shift; cmd_verify "$@" ;; set-schedule) shift; cmd_set_schedule "$@" ;; notify-test) shift; cmd_notify_test "$@" ;; list-databases) shift; cmd_list_databases "$@" ;; -h|--help|help|"") usage 0 ;; *) echo "unknown command: $1" >&2; usage 2 ;; esac