diff --git a/bin/borg-apiscp-repo b/bin/borg-apiscp-repo new file mode 100644 index 0000000..a7f1b6b --- /dev/null +++ b/bin/borg-apiscp-repo @@ -0,0 +1,362 @@ +#!/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 diff --git a/bin/borg-apiscp-restore b/bin/borg-apiscp-restore new file mode 100644 index 0000000..e4219ce --- /dev/null +++ b/bin/borg-apiscp-restore @@ -0,0 +1,255 @@ +#!/bin/sh +# +# borg-apiscp-restore: restore ApisCP data from a Borg repository. +# +# Borg stores archived paths relative (leading "/" stripped) and preserves +# POSIX ACLs and xattrs natively, so restore is a plain `borg extract` with +# `--strip-components` to rebase the archived path under a staging target. No +# ACL/xattr replay is needed (that is the kopia engine's job, not Borg's). +# +# Archive naming (from the engine): siteN-shadow-{now}, siteN-info-{now}, +# siteN-db-{now}, _system-{now}, _custom--{now}. "newest" means the most +# recent archive of a given prefix. +# +# Usage: +# borg-apiscp-restore list [PREFIX] +# List archives, optionally filtered to a prefix glob (e.g. "site1"). +# borg-apiscp-restore list-json PREFIX +# Raw `borg list --json` for archives matching PREFIX-* (for the module). +# borg-apiscp-restore site SITE TARGET [--subpath P ...] +# Restore SITE's newest shadow (+ info) into TARGET/{shadow,info}. With +# --subpath, restore only those paths (relative to the site root). +# borg-apiscp-restore account SITE TARGET +# Restore a whole account: files (shadow+info) plus all databases (newest +# SITE-db archive) into TARGET, databases under TARGET/databases/. +# borg-apiscp-restore system TARGET +# Restore the newest _system archive into TARGET. +# borg-apiscp-restore restore-db SITE ENGINE DB TARGET +# Restore one database dump (DB.sql) from the newest SITE-db archive into +# TARGET/ (non-destructive). +# borg-apiscp-restore import-db SITE ENGINE DB +# DESTRUCTIVE: restore the newest dump of SITE's DB and import it into the +# live database via ApisCP's native, site-context import. +# borg-apiscp-restore owner-files SITE [--subpath P ...] +# borg-apiscp-restore owner-account SITE +# borg-apiscp-restore owner-restore-db SITE ENGINE DB +# borg-apiscp-restore owner-import-db SITE ENGINE DB +# Site-owner variants: restore INTO the site's own filesystem under +# /.borg-restore/, chowned to the site owner; print the in-site path. +# SITE is supplied by the trusted site-owner module (auth context). + +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_RSH:=ssh -o BatchMode=yes}" +: "${VIRTBASE:=/home/virtual}" +: "${APNSCP_CMD:=/usr/local/apnscp/bin/cmd}" +export BORG_REPO BORG_RSH +[ -n "${BORG_PASSPHRASE:-}" ] && export BORG_PASSPHRASE +# Every real command needs a repository; help/usage does not. +case "${1:-}" in + -h|--help|help|"") ;; + *) [ -n "$BORG_REPO" ] || akp_die "BORG_REPO is not set (edit $CONFIG_FILE)" ;; +esac + +usage() { awk 'NR>=2 && /^set -u/{exit} NR>=2{sub(/^# ?/,""); print}' "$0"; exit "${1:-2}"; } + +# Number of path components in $1 (leading/trailing slashes ignored). +_strip_for() { printf '%s' "$1" | sed 's#^/*##; s#/*$##' | awk -F/ '{print NF}'; } +# Relative (leading-slash-stripped) form of a path, as Borg stores it. +_rel() { printf '%s' "$1" | sed 's#^/*##'; } +# Newest archive name for a prefix (PREFIX-*), or empty. +_newest() { akp_borg list --glob-archives "$1-*" --last 1 --format '{archive}{NL}' 2>/dev/null | head -1; } + +cmd_list() { + if [ -n "${1:-}" ]; then akp_borg list --glob-archives "$1-*"; else akp_borg list; fi +} +cmd_list_json() { + [ -n "${1:-}" ] || akp_die "list-json: PREFIX required" + akp_borg list --json --glob-archives "$1-*" +} + +# Extract SITE's shadow/info into TARGET (optionally only --subpath paths). +_extract_site_files() { + _site="$1"; _target="$2"; shift 2 + _subs="" + while [ $# -gt 0 ]; do case "$1" in --subpath) _subs="$_subs${2:-} +"; shift 2 ;; *) shift ;; esac; done + mkdir -p "$_target" || akp_die "cannot create $_target" + _sc=$(_strip_for "$VIRTBASE/$_site") + _any=0 + for _sub in shadow info; do + _arch=$(_newest "$_site-$_sub") + [ -n "$_arch" ] || continue + if [ -n "$_subs" ] && [ "$_sub" = shadow ]; then + # Selected paths only (relative to the site root, under shadow/). + _oifs=$IFS; IFS=' +' + for _sp in $_subs; do + [ -n "$_sp" ] || continue + case "$_sp" in /*|*..*) akp_warn "skip unsafe subpath $_sp"; continue ;; esac + if ( cd "$_target" && akp_borg extract --numeric-ids --strip-components "$_sc" "::$_arch" "$(_rel "$VIRTBASE/$_site")/shadow/$_sp" ); then + _any=1; akp_log "restored $_sub/$_sp" + else akp_warn "failed subpath $_sub/$_sp"; fi + done + IFS=$_oifs + else + if ( cd "$_target" && akp_borg extract --numeric-ids --strip-components "$_sc" "::$_arch" ); then + _any=1; akp_log "restored $_sub ($_arch)" + else akp_warn "failed to restore $_sub ($_arch)"; fi + fi + done + return $(( _any == 1 ? 0 : 1 )) +} + +cmd_site() { + _site="${1:-}"; _target="${2:-}"; shift 2 2>/dev/null || usage 2 + case "$_site" in site[0-9]*) ;; *) akp_die "site: invalid site '$_site'" ;; esac + [ -n "$_target" ] || usage 2 + _extract_site_files "$_site" "$_target" "$@" || akp_die "site: nothing restored for $_site" + echo "Restored $_site to $_target. Review, then copy back into place." >&2 +} + +# Extract all databases (newest SITE-db archive) into TARGET/databases. +_extract_site_dbs() { + _site="$1"; _target="$2" + _arch=$(_newest "$_site-db") + [ -n "$_arch" ] || { akp_warn "no database archive for $_site"; return 1; } + mkdir -p "$_target/databases" || return 1 + _sc=$(_strip_for "$VIRTBASE/$_site/fst/tmp/.apiscp-borg-db") + ( cd "$_target/databases" && akp_borg extract --strip-components "$_sc" "::$_arch" ) \ + && akp_log "restored databases ($_arch) -> $_target/databases" \ + || { akp_warn "failed to restore databases for $_site"; return 1; } +} + +cmd_account() { + _site="${1:-}"; _target="${2:-}" + case "$_site" in site[0-9]*) ;; *) akp_die "account: invalid site '$_site'" ;; esac + [ -n "$_target" ] || usage 2 + mkdir -p "$_target" || akp_die "account: cannot create $_target" + _extract_site_files "$_site" "$_target" || akp_die "account: no file archives for $_site" + _extract_site_dbs "$_site" "$_target" || akp_warn "account: no databases restored (files only)" + echo "Restored account $_site to $_target (files + databases under databases/)." >&2 +} + +cmd_system() { + _target="${1:-}"; [ -n "$_target" ] || usage 2 + _arch=$(_newest "_system") + [ -n "$_arch" ] || akp_die "system: no _system archive found" + mkdir -p "$_target" || akp_die "system: cannot create $_target" + ( cd "$_target" && akp_borg extract --numeric-ids "::$_arch" ) || akp_die "system: extract failed" + akp_log "restored system archive $_arch -> $_target" + echo "Restored system paths to $_target. Review, then copy back into place." >&2 +} + +# restore-db SITE ENGINE DB TARGET: extract one dump to TARGET/DB.sql. +cmd_restore_db() { + _site="${1:-}"; _eng="${2:-}"; _db="${3:-}"; _target="${4:-}" + case "$_site" in site[0-9]*) ;; *) akp_die "restore-db: invalid site '$_site'" ;; esac + case "$_eng" in mysql|pgsql) ;; *) akp_die "restore-db: ENGINE must be mysql or pgsql" ;; esac + case "$_db" in ''|*[!A-Za-z0-9_-]*) akp_die "restore-db: invalid DB name '$_db'" ;; esac + [ -n "$_target" ] || akp_die "restore-db: TARGET required" + _arch=$(_newest "$_site-db"); [ -n "$_arch" ] || akp_die "restore-db: no database archive for $_site" + mkdir -p "$_target" || akp_die "restore-db: cannot create $_target" + _sc=$(_strip_for "$VIRTBASE/$_site/fst/tmp/.apiscp-borg-db/$_eng") + _relpath="$(_rel "$VIRTBASE/$_site")/fst/tmp/.apiscp-borg-db/$_eng/$_db.sql" + ( cd "$_target" && akp_borg extract --strip-components "$_sc" "::$_arch" "$_relpath" ) \ + || akp_die "restore-db: could not extract $_eng/$_db.sql from $_arch" + [ -f "$_target/$_db.sql" ] || akp_die "restore-db: $_db.sql not found in archive" + akp_log "restored $_eng db $_db for $_site to $_target/$_db.sql" + printf '%s\n' "$_target/$_db.sql" +} + +# import-db SITE ENGINE DB: restore newest dump and import into the live DB. +cmd_import_db() { + _site="${1:-}"; _eng="${2:-}"; _db="${3:-}" + case "$_site" in site[0-9]*) ;; *) akp_die "import-db: invalid site '$_site'" ;; esac + case "$_eng" in mysql|pgsql) ;; *) akp_die "import-db: ENGINE must be mysql or pgsql" ;; esac + case "$_db" in ''|*[!A-Za-z0-9_-]*) akp_die "import-db: invalid DB name '$_db'" ;; esac + _dom=$(akp_site_domain "$_site"); [ -n "$_dom" ] || akp_die "import-db: no domain for $_site" + _tmp=$(mktemp -d) || akp_die "import-db: cannot stage" + cmd_restore_db "$_site" "$_eng" "$_db" "$_tmp" >/dev/null + # ApisCP import resolves the path inside the site fs namespace: stage the dump + # under the site fst and import via the site-relative path. + _srel="/tmp/.apiscp-borg-import"; _fst=$(akp_site_fst "$_site") + [ -d "$_fst" ] || { rm -rf "$_tmp"; akp_die "import-db: site fst not found ($_fst)"; } + _stage="$_fst$_srel"; mkdir -p "$_stage" || { rm -rf "$_tmp"; akp_die "import-db: cannot stage under $_stage"; } + cp "$_tmp/$_db.sql" "$_stage/$_db.sql" || { rm -rf "$_tmp" "$_stage"; akp_die "import-db: cannot stage dump"; } + chmod 0644 "$_stage/$_db.sql" 2>/dev/null || true + akp_log "importing $_eng db $_db into $_site ($_dom) (DESTRUCTIVE)" + _rc=0; "$APNSCP_CMD" -d "$_dom" "$_eng:import" "$_db" "$_srel/$_db.sql" || _rc=1 + rm -rf "$_stage" "$_tmp" + [ "$_rc" = 0 ] || akp_die "import-db: import of $_db failed" + akp_log "imported $_eng db $_db into $_site" +} + +# --- site-owner variants: restore into the site's own fs, chowned ------------ + +_owner_target() { printf '%s%s' "$(akp_site_fst "$1")" "/.borg-restore/$2-$(date +%s)"; } + +cmd_owner_files() { + _site="${1:-}"; [ $# -ge 1 ] && shift || usage 2 + case "$_site" in site[0-9]*) ;; *) akp_die "owner-files: invalid site '$_site'" ;; esac + _fst=$(akp_site_fst "$_site"); [ -d "$_fst" ] || akp_die "owner-files: site fst not found" + _rel="/.borg-restore/files-$(date +%s)"; _target="$_fst$_rel" + _extract_site_files "$_site" "$_target" "$@" || { rm -rf "$_target"; akp_die "owner-files: nothing restored"; } + akp_owner_chown "$_site" "$_fst/.borg-restore" || true + printf '%s\n' "$_rel" +} + +cmd_owner_account() { + _site="${1:-}" + case "$_site" in site[0-9]*) ;; *) akp_die "owner-account: invalid site '$_site'" ;; esac + _fst=$(akp_site_fst "$_site"); [ -d "$_fst" ] || akp_die "owner-account: site fst not found" + _rel="/.borg-restore/account-$(date +%s)"; _target="$_fst$_rel" + mkdir -p "$_target" || akp_die "owner-account: cannot create restore dir" + _extract_site_files "$_site" "$_target" || { rm -rf "$_target"; akp_die "owner-account: no file archives"; } + _extract_site_dbs "$_site" "$_target" || akp_warn "owner-account: no databases restored" + akp_owner_chown "$_site" "$_fst/.borg-restore" || true + printf '%s\n' "$_rel" +} + +cmd_owner_restore_db() { + _site="${1:-}"; _eng="${2:-}"; _db="${3:-}" + case "$_site" in site[0-9]*) ;; *) akp_die "owner-restore-db: invalid site '$_site'" ;; esac + _fst=$(akp_site_fst "$_site"); [ -d "$_fst" ] || akp_die "owner-restore-db: site fst not found" + _rel="/.borg-restore/db-$(date +%s)"; _target="$_fst$_rel" + cmd_restore_db "$_site" "$_eng" "$_db" "$_target" >/dev/null + akp_owner_chown "$_site" "$_fst/.borg-restore" || true + printf '%s/%s.sql\n' "$_rel" "$_db" +} + +cmd_owner_import_db() { + _site="${1:-}"; _eng="${2:-}"; _db="${3:-}" + case "$_site" in site[0-9]*) ;; *) akp_die "owner-import-db: invalid site '$_site'" ;; esac + cmd_import_db "$_site" "$_eng" "$_db" +} + +case "${1:-}" in + list) shift; cmd_list "$@" ;; + list-json) shift; cmd_list_json "$@" ;; + site) shift; cmd_site "$@" ;; + account) shift; cmd_account "$@" ;; + system) shift; cmd_system "$@" ;; + restore-db) shift; cmd_restore_db "$@" ;; + import-db) shift; cmd_import_db "$@" ;; + owner-files) shift; cmd_owner_files "$@" ;; + owner-account) shift; cmd_owner_account "$@" ;; + owner-restore-db) shift; cmd_owner_restore_db "$@" ;; + owner-import-db) shift; cmd_owner_import_db "$@" ;; + -h|--help|help|"") usage 0 ;; + *) echo "unknown command: $1" >&2; usage 2 ;; +esac