#!/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