Initial scaffold: Borg engine + docs (Layer 1 milestone)
apiscp-borg is a sibling of apiscp-kopia built on BorgBackup. This first milestone lands the project scaffold and the Layer 1 engine: - borg-apiscp-backup: per-site named archives (siteN-shadow/info/db), fresh per-site DB dumps via ApisCP site-context export, system + custom archives, borg prune retention per prefix, Prometheus metrics, email notifications. - apiscp-borg-common.sh: logging, mail, site/owner helpers. - config example, systemd service+timer, install.sh, README, DESIGN. Borg preserves POSIX ACLs and xattrs natively, so (unlike the kopia engine) no metadata sidecar is required; DESIGN.md records how Borg reshapes the design. Repository/restore tools, Layer 2 panel integration, hooks, uninstall, and the full reference are the next milestones.
This commit is contained in:
commit
2f0d93a0ae
10 changed files with 743 additions and 0 deletions
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Local editor / OS noise
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Never commit secrets or local state
|
||||||
|
etc/apiscp-borg.config
|
||||||
|
*.key
|
||||||
|
*.passphrase
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Laurence Horrocks-Barlow
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
53
README.md
Normal file
53
README.md
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# apiscp-borg
|
||||||
|
|
||||||
|
A [BorgBackup](https://www.borgbackup.org/) backup engine for
|
||||||
|
[ApisCP](https://apiscp.com), plus native panel/CLI integration, packaged so it
|
||||||
|
survives ApisCP upgrades. Sibling project to
|
||||||
|
[apiscp-kopia](https://git.discworld.casa/laurence/apiscp-kopia): same
|
||||||
|
architecture, different backend.
|
||||||
|
|
||||||
|
## Why Borg, and why a plugin
|
||||||
|
|
||||||
|
Borg is a fast, deduplicating, compressing, encrypted backup tool. Unlike kopia,
|
||||||
|
**Borg preserves POSIX ACLs and extended attributes natively**, which is exactly
|
||||||
|
the metadata ApisCP encodes account permissions in. So a plugin is not needed to
|
||||||
|
work around lost metadata (the reason apiscp-kopia exists). The value here is:
|
||||||
|
|
||||||
|
- runs ApisCP's `backup_dbs.php` before archiving, so per-site database dumps
|
||||||
|
are current instead of a day stale;
|
||||||
|
- archives each site as its own named set (`siteN-shadow-...`, `siteN-db-...`),
|
||||||
|
enabling single-site restore and per-site retention;
|
||||||
|
- captures per-site databases through ApisCP's site-context export (a host-level
|
||||||
|
dump cannot see them);
|
||||||
|
- wires retention (`borg prune`), maintenance (`borg compact`), integrity
|
||||||
|
(`borg check`), scheduling, email notifications, one-time key backup, and both
|
||||||
|
an appliance-admin and a site-owner GUI into ApisCP.
|
||||||
|
|
||||||
|
See `docs/DESIGN.md` for how Borg changes the design relative to apiscp-kopia.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Two decoupled layers, both upgrade-safe:
|
||||||
|
|
||||||
|
- **Layer 1: the engine.** A standalone POSIX-sh program on a systemd timer,
|
||||||
|
with no coupling to ApisCP's PHP internals. `bin/borg-apiscp-backup` plus
|
||||||
|
`borg-apiscp-repo` and `borg-apiscp-restore`.
|
||||||
|
- **Layer 2: native integration.** An ApisCP module, GUI apps, and account hooks
|
||||||
|
under `/usr/local/apnscp/config/custom`, which survive `upcp`.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- ApisCP (any recent release)
|
||||||
|
- `borgbackup` (`borg`) 1.2+ on the host (and on the remote for ssh backends)
|
||||||
|
- a filesystem that carries ACLs and xattrs (default on ext4/xfs/btrfs)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Early scaffold. The Layer 1 engine is the first milestone; the repository and
|
||||||
|
restore tools, Layer 2 panel integration (module + two GUIs + hooks), install /
|
||||||
|
uninstall, and full reference documentation are being built out to track the
|
||||||
|
apiscp-kopia feature set. Not yet ready for production use.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT. See `LICENSE`.
|
||||||
305
bin/borg-apiscp-backup
Normal file
305
bin/borg-apiscp-backup
Normal file
|
|
@ -0,0 +1,305 @@
|
||||||
|
#!/bin/sh
|
||||||
|
#
|
||||||
|
# borg-apiscp-backup: an ApisCP-aware backup engine for BorgBackup.
|
||||||
|
#
|
||||||
|
# On every run, in order:
|
||||||
|
# 1. Refresh ApisCP's per-site database dumps (backup_dbs.php) FIRST, so the
|
||||||
|
# logical .sql exports under each site are current before archiving them.
|
||||||
|
# 2. Create one Borg archive per site subtree (shadow, info), named with a
|
||||||
|
# structured prefix (siteN-shadow-{now}) so per-site restore and retention
|
||||||
|
# are glob-addressable. Borg preserves POSIX ACLs and xattrs natively, so
|
||||||
|
# no metadata sidecar is required (unlike the kopia engine).
|
||||||
|
# 3. Dump and archive each site's databases via ApisCP's site-context export.
|
||||||
|
# 4. Archive the shared system paths (_system-{now}) and any custom paths.
|
||||||
|
# 5. Apply retention with `borg prune` per archive prefix.
|
||||||
|
# 6. Emit Prometheus textfile metrics and, if configured, email the outcome.
|
||||||
|
#
|
||||||
|
# Config: /etc/apiscp-borg/config (see apiscp-borg.config.example).
|
||||||
|
# Exit status: 0 if every archive was created, 1 if any failed.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
# --- locate the shared library -------------------------------------------------
|
||||||
|
_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; }
|
||||||
|
|
||||||
|
# --- configuration -------------------------------------------------------------
|
||||||
|
CONFIG_FILE="${APISCP_BORG_CONFIG:-/etc/apiscp-borg/config}"
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
[ -r "$CONFIG_FILE" ] && . "$CONFIG_FILE"
|
||||||
|
|
||||||
|
: "${APNSCP_ROOT:=/usr/local/apnscp}"
|
||||||
|
: "${APNSCP_CMD:=/usr/local/apnscp/bin/cmd}"
|
||||||
|
: "${VIRTBASE:=/home/virtual}"
|
||||||
|
: "${STAGE:=/var/lib/apiscp-borg}"
|
||||||
|
: "${RUN_DB_EXPORT:=1}"
|
||||||
|
: "${DB_EXPORT_CMD:=$APNSCP_ROOT/bin/scripts/backup_dbs.php}"
|
||||||
|
: "${SYSTEM_PATHS:=/etc /opt}"
|
||||||
|
: "${SYSTEM_GLOBS:=/var/log/mailer_table* /var/lib/mysql/mysql-grants* /var/lib/pgsql/*/backups /root/apnscp* /root/license*}"
|
||||||
|
: "${METRICS_DIR:=}"
|
||||||
|
: "${LOCK_FILE:=/run/apiscp-borg.lock}"
|
||||||
|
: "${BACKUP_SITES:=all}"
|
||||||
|
: "${BACKUP_SYSTEM:=1}"
|
||||||
|
: "${BACKUP_DATABASES:=1}"
|
||||||
|
: "${BACKUP_PATHS:=}"
|
||||||
|
: "${BACKUP_EXCLUDES:=}"
|
||||||
|
: "${CAPTURE_META_SIDECAR:=0}"
|
||||||
|
: "${NOTIFY_EMAIL:=}"
|
||||||
|
: "${NOTIFY_ON:=failure}"
|
||||||
|
: "${NOTIFY_FROM:=}"
|
||||||
|
: "${RETENTION_KEEP_WITHIN:=}"
|
||||||
|
: "${RETENTION_KEEP_DAILY:=}"
|
||||||
|
: "${RETENTION_KEEP_WEEKLY:=}"
|
||||||
|
: "${RETENTION_KEEP_MONTHLY:=}"
|
||||||
|
: "${RETENTION_KEEP_ANNUAL:=}"
|
||||||
|
: "${BORG_REPO:=}"
|
||||||
|
: "${BORG_RSH:=ssh -o BatchMode=yes}"
|
||||||
|
|
||||||
|
[ -n "$BORG_REPO" ] || akp_die "BORG_REPO is not set (edit $CONFIG_FILE)"
|
||||||
|
export BORG_REPO BORG_RSH
|
||||||
|
[ -n "${BORG_PASSPHRASE:-}" ] && export BORG_PASSPHRASE
|
||||||
|
# Do not block on interactive prompts (unknown repo, etc.) in an unattended run.
|
||||||
|
export BORG_RELOCATED_REPO_ACCESS_IS_OK="${BORG_RELOCATED_REPO_ACCESS_IS_OK:-no}"
|
||||||
|
|
||||||
|
# akp_site_selected SITE: succeed if SITE should be backed up per BACKUP_SITES.
|
||||||
|
akp_site_selected() {
|
||||||
|
[ "$BACKUP_SITES" = "all" ] && return 0
|
||||||
|
[ "$BACKUP_SITES" = "none" ] && return 1
|
||||||
|
_want="$1"; _ifs=$IFS; IFS=,
|
||||||
|
for _s in $BACKUP_SITES; do
|
||||||
|
_s=$(printf '%s' "$_s" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
||||||
|
[ "$_s" = "$_want" ] && { IFS=$_ifs; return 0; }
|
||||||
|
done
|
||||||
|
IFS=$_ifs; return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- single-instance lock ------------------------------------------------------
|
||||||
|
if command -v flock >/dev/null 2>&1; then
|
||||||
|
exec 9>"$LOCK_FILE" || akp_die "cannot open lock $LOCK_FILE"
|
||||||
|
flock -n 9 || akp_die "another run holds $LOCK_FILE; aborting"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- metrics + notify accounting ----------------------------------------------
|
||||||
|
_start_epoch=$(date +%s 2>/dev/null || echo 0)
|
||||||
|
OK=0
|
||||||
|
FAILED=0
|
||||||
|
DB_EXPORT_RC=-1
|
||||||
|
_reached_end=0
|
||||||
|
|
||||||
|
_detect_metrics_dir() {
|
||||||
|
[ -n "$METRICS_DIR" ] && { printf '%s' "$METRICS_DIR"; return; }
|
||||||
|
for d in /var/lib/node_exporter/textfile_collector /var/lib/prometheus/node-exporter /var/lib/prometheus/node_exporter; do
|
||||||
|
[ -d "$d" ] && { printf '%s' "$d"; return; }
|
||||||
|
done
|
||||||
|
printf ''
|
||||||
|
}
|
||||||
|
|
||||||
|
write_metrics() {
|
||||||
|
_mdir=$(_detect_metrics_dir)
|
||||||
|
[ -n "$_mdir" ] || { akp_warn "no textfile collector dir; metrics not written"; return; }
|
||||||
|
_now=$(date +%s 2>/dev/null || echo 0)
|
||||||
|
_dur=$(( _now - _start_epoch ))
|
||||||
|
_run_ok=0; [ "$FAILED" -eq 0 ] && _run_ok=1
|
||||||
|
_tmp="$_mdir/.apiscp-borg.prom.$$"
|
||||||
|
{
|
||||||
|
echo "# HELP apiscp_borg_run_success Whether the last run archived every source (1) or not (0)."
|
||||||
|
echo "# TYPE apiscp_borg_run_success gauge"
|
||||||
|
echo "apiscp_borg_run_success $_run_ok"
|
||||||
|
echo "# HELP apiscp_borg_archives_ok Archives created successfully in the last run."
|
||||||
|
echo "# TYPE apiscp_borg_archives_ok gauge"
|
||||||
|
echo "apiscp_borg_archives_ok $OK"
|
||||||
|
echo "# HELP apiscp_borg_archives_failed Archives that failed in the last run."
|
||||||
|
echo "# TYPE apiscp_borg_archives_failed gauge"
|
||||||
|
echo "apiscp_borg_archives_failed $FAILED"
|
||||||
|
echo "# HELP apiscp_borg_duration_seconds Wall-clock duration of the last run."
|
||||||
|
echo "# TYPE apiscp_borg_duration_seconds gauge"
|
||||||
|
echo "apiscp_borg_duration_seconds $_dur"
|
||||||
|
echo "# HELP apiscp_borg_db_export_success backup_dbs.php exit (1 ok, 0 failed, -1 skipped)."
|
||||||
|
echo "# TYPE apiscp_borg_db_export_success gauge"
|
||||||
|
echo "apiscp_borg_db_export_success $DB_EXPORT_RC"
|
||||||
|
echo "# HELP apiscp_borg_last_run_timestamp_seconds Unix time the last run finished."
|
||||||
|
echo "# TYPE apiscp_borg_last_run_timestamp_seconds gauge"
|
||||||
|
echo "apiscp_borg_last_run_timestamp_seconds $_now"
|
||||||
|
} > "$_tmp" 2>/dev/null && mv -f "$_tmp" "$_mdir/apiscp-borg.prom" 2>/dev/null \
|
||||||
|
|| akp_warn "failed to write metrics to $_mdir"
|
||||||
|
}
|
||||||
|
|
||||||
|
notify() {
|
||||||
|
[ -n "${NOTIFY_EMAIL:-}" ] || return 0
|
||||||
|
_n_ok=0; { [ "$FAILED" -eq 0 ] && [ "$_reached_end" -eq 1 ]; } && _n_ok=1
|
||||||
|
case "${NOTIFY_ON:-failure}" in
|
||||||
|
never) return 0 ;;
|
||||||
|
always) ;;
|
||||||
|
*) [ "$_n_ok" -eq 1 ] && return 0 ;;
|
||||||
|
esac
|
||||||
|
_n_host=$(hostname 2>/dev/null || echo apiscp)
|
||||||
|
_n_dur=$(( $(date +%s 2>/dev/null || echo 0) - _start_epoch ))
|
||||||
|
_n_res=$( [ "$_n_ok" -eq 1 ] && echo success || echo FAILURE )
|
||||||
|
_n_db=$( case "$DB_EXPORT_RC" in 1) echo ok ;; 0) echo failed ;; *) echo skipped ;; esac )
|
||||||
|
_n_body=$(printf 'apiscp-borg backup run on %s\n\nResult: %s\nFinished: %s\nArchives: %s ok, %s failed\nDatabases: %s\nDuration: %ss\nRepository: %s\n' \
|
||||||
|
"$_n_host" "$_n_res" "$(_akp_now)" "$OK" "$FAILED" "$_n_db" "$_n_dur" "$BORG_REPO")
|
||||||
|
[ "$_n_ok" -eq 1 ] || _n_body="$_n_body
|
||||||
|
See 'journalctl -t ${APISCP_BORG_LOG_TAG:-apiscp-borg}' on $_n_host for details."
|
||||||
|
akp_send_mail "$NOTIFY_EMAIL" "[apiscp-borg] backup $_n_res on $_n_host" "$_n_body" "${NOTIFY_FROM:-}" \
|
||||||
|
&& akp_log "notification emailed to $NOTIFY_EMAIL" || akp_warn "notification email failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
on_exit() { write_metrics; notify; }
|
||||||
|
trap on_exit EXIT INT TERM
|
||||||
|
|
||||||
|
# Build the shared --exclude argument list (always exclude the restore staging).
|
||||||
|
_add_excludes() {
|
||||||
|
set -- --exclude '*/.borg-restore' --exclude '/.borg-restore'
|
||||||
|
_oifs=$IFS; IFS=',
|
||||||
|
'
|
||||||
|
for _p in ${BACKUP_EXCLUDES:-}; do
|
||||||
|
[ -n "$_p" ] && set -- "$@" --exclude "$_p"
|
||||||
|
done
|
||||||
|
IFS=$_oifs
|
||||||
|
EXCLUDE_ARGS="$*" # only used for logging; real calls use "$@" below
|
||||||
|
BORG_EXCLUDES_SET=1
|
||||||
|
# Export via positional re-use is awkward in sh; callers rebuild instead.
|
||||||
|
}
|
||||||
|
|
||||||
|
# create_archive NAME PATH...: borg create with excludes; update counters.
|
||||||
|
create_archive() {
|
||||||
|
_name="$1"; shift
|
||||||
|
[ "$#" -ge 1 ] || return 0
|
||||||
|
# Assemble excludes fresh each call.
|
||||||
|
set -- "$@" # keep source paths in "$@"
|
||||||
|
_srcs="$*"
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
if _create_with_excludes "$_name" "$@"; then
|
||||||
|
OK=$(( OK + 1 )); akp_log "archived $_name"
|
||||||
|
else
|
||||||
|
FAILED=$(( FAILED + 1 )); akp_warn "archive FAILED: $_name ($_srcs)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# _create_with_excludes NAME SRC...: run borg create '::NAME-{now}' with excludes.
|
||||||
|
_create_with_excludes() {
|
||||||
|
_cn="$1"; shift
|
||||||
|
# Build: create [excludes] ::NAME-{now} SRC...
|
||||||
|
set -- ::"$_cn"-{now} "$@"
|
||||||
|
# Prepend excludes.
|
||||||
|
_ex_oifs=$IFS; IFS=',
|
||||||
|
'
|
||||||
|
for _p in ${BACKUP_EXCLUDES:-}; do
|
||||||
|
[ -n "$_p" ] && set -- --exclude "$_p" "$@"
|
||||||
|
done
|
||||||
|
IFS=$_ex_oifs
|
||||||
|
set -- --exclude '*/.borg-restore' --exclude '/.borg-restore' "$@"
|
||||||
|
akp_borg create --compression "${BORG_COMPRESSION:-zstd}" "$@" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# prune_prefix PREFIX: apply retention to archives named PREFIX-* if any keep rule set.
|
||||||
|
prune_prefix() {
|
||||||
|
_pref="$1"
|
||||||
|
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 ] || return 0
|
||||||
|
if akp_borg prune --glob-archives "$_pref-*" "$@" >/dev/null 2>&1; then
|
||||||
|
akp_log "pruned $_pref-* per retention"
|
||||||
|
else
|
||||||
|
akp_warn "prune failed for $_pref-*"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
akp_log "run start (repo: $BORG_REPO)"
|
||||||
|
|
||||||
|
# 1. Refresh per-site database exports before archiving them.
|
||||||
|
if [ "$RUN_DB_EXPORT" = "1" ]; then
|
||||||
|
if [ -x "$DB_EXPORT_CMD" ] || [ -r "$DB_EXPORT_CMD" ]; then
|
||||||
|
akp_log "running ApisCP database export: $DB_EXPORT_CMD"
|
||||||
|
if "$DB_EXPORT_CMD" >/dev/null 2>&1; then DB_EXPORT_RC=1; else DB_EXPORT_RC=0; akp_warn "database export returned non-zero"; fi
|
||||||
|
else
|
||||||
|
akp_warn "DB_EXPORT_CMD not found ($DB_EXPORT_CMD); skipping"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2 + 3. Per-site archives and databases.
|
||||||
|
if [ -d "$VIRTBASE" ]; then
|
||||||
|
for sitedir in "$VIRTBASE"/site[0-9]*; do
|
||||||
|
[ -d "$sitedir" ] || continue
|
||||||
|
site=$(basename "$sitedir")
|
||||||
|
akp_site_selected "$site" || { akp_log "skip $site (not in BACKUP_SITES)"; continue; }
|
||||||
|
|
||||||
|
[ -d "$sitedir/shadow" ] && create_archive "$site-shadow" "$sitedir/shadow"
|
||||||
|
[ -d "$sitedir/info" ] && create_archive "$site-info" "$sitedir/info"
|
||||||
|
|
||||||
|
if [ "$BACKUP_DATABASES" = "1" ]; then
|
||||||
|
dbdom=$(akp_site_domain "$site")
|
||||||
|
if [ -z "$dbdom" ]; then
|
||||||
|
akp_warn "no domain for $site; skipping database dump"
|
||||||
|
elif [ ! -x "$APNSCP_CMD" ] && [ ! -r "$APNSCP_CMD" ]; then
|
||||||
|
akp_warn "APNSCP_CMD not found ($APNSCP_CMD); skipping database dump for $site"
|
||||||
|
else
|
||||||
|
db_siterel="/tmp/.apiscp-borg-db"
|
||||||
|
dbroot="$sitedir/fst$db_siterel"
|
||||||
|
rm -rf "$dbroot"
|
||||||
|
_dbany=0
|
||||||
|
for eng in mysql pgsql; do
|
||||||
|
dblist=$("$APNSCP_CMD" -o json -d "$dbdom" "$eng:list_databases" 2>/dev/null) || continue
|
||||||
|
[ -n "$dblist" ] || continue
|
||||||
|
for db in $(printf '%s\n' "$dblist" | grep -oE '"[A-Za-z0-9_-]+"' | sed 's/"//g'); do
|
||||||
|
[ -n "$db" ] || continue
|
||||||
|
if "$APNSCP_CMD" -d "$dbdom" "$eng:export" "$db" "$db_siterel/$eng/$db.sql" >/dev/null 2>&1; then
|
||||||
|
_dbany=1; akp_log "exported $eng db $db for $site"
|
||||||
|
else
|
||||||
|
akp_warn "failed to export $eng db $db for $site"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
[ "$_dbany" = 1 ] && [ -d "$dbroot" ] && create_archive "$site-db" "$dbroot"
|
||||||
|
rm -rf "$dbroot"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Per-site retention.
|
||||||
|
prune_prefix "$site-shadow"
|
||||||
|
prune_prefix "$site-info"
|
||||||
|
prune_prefix "$site-db"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
akp_warn "VIRTBASE $VIRTBASE not present; no per-site backups"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Shared system paths (one archive), then custom paths.
|
||||||
|
if [ "$BACKUP_SYSTEM" = "1" ]; then
|
||||||
|
# Expand globs; keep only existing paths.
|
||||||
|
set --
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
for p in $SYSTEM_PATHS $SYSTEM_GLOBS; do
|
||||||
|
[ -e "$p" ] && set -- "$@" "$p"
|
||||||
|
done
|
||||||
|
[ "$#" -ge 1 ] && create_archive "_system" "$@"
|
||||||
|
prune_prefix "_system"
|
||||||
|
else
|
||||||
|
akp_log "skipping shared system paths (BACKUP_SYSTEM=0)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$BACKUP_PATHS" ]; then
|
||||||
|
_paths=$(printf '%s' "$BACKUP_PATHS" | tr ',' ' ')
|
||||||
|
for cp in $_paths; do
|
||||||
|
[ -e "$cp" ] || { akp_warn "skip custom path (missing): $cp"; continue; }
|
||||||
|
slug=$(akp_slug "$cp")
|
||||||
|
create_archive "_custom-$slug" "$cp"
|
||||||
|
prune_prefix "_custom-$slug"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
akp_log "run done: $OK ok, $FAILED failed"
|
||||||
|
_reached_end=1
|
||||||
|
[ "$FAILED" -eq 0 ]
|
||||||
101
docs/DESIGN.md
Normal file
101
docs/DESIGN.md
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# Design
|
||||||
|
|
||||||
|
apiscp-borg is a sibling of apiscp-kopia: the same ApisCP integration
|
||||||
|
architecture, but built on [BorgBackup](https://www.borgbackup.org/) instead of
|
||||||
|
kopia. This document records how Borg changes the design.
|
||||||
|
|
||||||
|
## What is the same as apiscp-kopia
|
||||||
|
|
||||||
|
- **Two upgrade-safe layers.** Layer 1 is a standalone POSIX-sh engine plus
|
||||||
|
tools, driven by a systemd timer, with zero coupling to ApisCP's PHP. Layer 2
|
||||||
|
is an ApisCP module, GUI apps, and account hooks under
|
||||||
|
`/usr/local/apnscp/config/custom` (git-ignored, so they survive `upcp`).
|
||||||
|
- **Runs `backup_dbs.php` first** so per-site logical database dumps are current
|
||||||
|
before they are archived. A plain cron job that runs before ApisCP's own
|
||||||
|
`10backup_dbs` captures day-stale dumps; running it in the engine fixes that.
|
||||||
|
- **Per-site granularity.** Each site is archived as its own named set so a
|
||||||
|
single site can be restored and retention can be reasoned about per site.
|
||||||
|
- **Per-site database handling through ApisCP's site-context API.** Host
|
||||||
|
`mysqldump` cannot see per-site databases, so the engine dumps each site's
|
||||||
|
databases with `cpcmd -d <domain> mysql:export` (and pgsql), which resolves
|
||||||
|
the target path INSIDE the site filesystem namespace, then archives the dump
|
||||||
|
from its real host location under the site's `fst` tree and removes it. Import
|
||||||
|
is the guarded native `mysql:import` / `pgsql:import`.
|
||||||
|
- **Security boundary.** The panel runs the module as the unprivileged `apnscp`
|
||||||
|
user; a scoped `/etc/sudoers.d/apiscp-borg` lets it run only the plugin
|
||||||
|
binaries as root, and the binaries validate their own input. Site-owner verbs
|
||||||
|
derive the site from the ApisCP auth context and never trust a site parameter.
|
||||||
|
- **Editions.** An appliance-admin edition and a site-owner ("My Backups")
|
||||||
|
edition scoped to a single account.
|
||||||
|
- **Retention, schedule, maintenance/verify, email notifications, one-time key
|
||||||
|
backup, and lifecycle hooks**, mirroring apiscp-kopia.
|
||||||
|
|
||||||
|
## What Borg changes (and why the rationale differs)
|
||||||
|
|
||||||
|
1. **ACLs and extended attributes are preserved natively.** This is the single
|
||||||
|
biggest difference from kopia. `borg create` stores POSIX ACLs and xattrs by
|
||||||
|
default on Linux, and `borg extract` restores them (use `--numeric-ids` for
|
||||||
|
servers so ownership maps by id, matching ApisCP's account model). apiscp-kopia
|
||||||
|
exists largely *because* kopia drops that metadata and needs ACL/xattr
|
||||||
|
sidecars; Borg needs no sidecars. The plugin's value here is instead: correct
|
||||||
|
ordering (fresh DB dumps), per-site archives, retention (`borg prune`),
|
||||||
|
encryption and passphrase safety, and native ApisCP GUI/CLI integration.
|
||||||
|
(An optional sidecar capture remains available as belt-and-braces but is off
|
||||||
|
by default because Borg already preserves the metadata.)
|
||||||
|
|
||||||
|
2. **Archives, not tags.** Borg has named archives in a repository rather than
|
||||||
|
kopia's tagged snapshots. The engine names archives with a structured prefix
|
||||||
|
so per-site operations are glob-addressable:
|
||||||
|
`siteN-shadow-{now}`, `siteN-info-{now}`, `siteN-db-{now}`, `_system-{now}`,
|
||||||
|
`_custom-<slug>-{now}` (timestamps in Borg's `{now}` placeholder). Restore
|
||||||
|
picks an archive by name; `borg list` enumerates them; per-site retention is
|
||||||
|
`borg prune --glob-archives 'siteN-*' --keep-*`.
|
||||||
|
|
||||||
|
3. **Retention is `borg prune`.** Retention runs as an explicit `borg prune`
|
||||||
|
after each backup (and can be run on demand), with keep-within/keep-daily/
|
||||||
|
weekly/monthly/annual rules from the config, applied per site prefix so one
|
||||||
|
site's churn cannot expire another's history.
|
||||||
|
|
||||||
|
4. **Maintenance is `borg compact`;** integrity is `borg check` (repository and
|
||||||
|
archive consistency, optionally `--verify-data` to read everything back).
|
||||||
|
|
||||||
|
5. **Transport and backends.** Borg speaks its own protocol over SSH. Backends
|
||||||
|
are a local path (`/mnt/...`) or a remote `ssh://user@host/path` (with a
|
||||||
|
dedicated SSH key and, ideally, an `append-only` `borg serve` restriction on
|
||||||
|
the server). There is no kopia-style repository server or sftp backend; ssh
|
||||||
|
is the remote story.
|
||||||
|
|
||||||
|
6. **Encryption and the key.** Repositories are created with
|
||||||
|
`--encryption=repokey-blake2` by default, so the encryption key lives inside
|
||||||
|
the repository and only the passphrase is needed to use it. That makes
|
||||||
|
recovery simpler than keyfile mode (which stores the key under
|
||||||
|
`~/.config/borg` and must be backed up separately). The one-time key backup
|
||||||
|
exports the passphrase plus `borg key export` output and stores it off the
|
||||||
|
machine; losing it makes the encrypted repository unrecoverable, exactly as
|
||||||
|
with kopia.
|
||||||
|
|
||||||
|
7. **Single-writer locking.** A Borg repository is locked to one operation at a
|
||||||
|
time. The engine holds a local lock and relies on Borg's own repository lock;
|
||||||
|
concurrent site-owner actions queue rather than run in parallel.
|
||||||
|
|
||||||
|
## Layer 1 (this scaffold)
|
||||||
|
|
||||||
|
- `bin/borg-apiscp-backup`: the engine (this milestone).
|
||||||
|
- `bin/borg-apiscp-repo`: repository init/connect, key backup, retention/prune,
|
||||||
|
maintenance (compact), check, schedule, notifications. (Next.)
|
||||||
|
- `bin/borg-apiscp-restore`: per-site, subpath, whole-account, and system
|
||||||
|
restore via `borg extract`, plus site-owner-scoped variants. (Next.)
|
||||||
|
- `lib/apiscp-borg-common.sh`: shared helpers (logging, mail, site helpers,
|
||||||
|
optional metadata sidecar).
|
||||||
|
|
||||||
|
## Layer 2 (next)
|
||||||
|
|
||||||
|
The ApisCP module (`Borg_Module_Surrogate`), the admin GUI app, the site-owner
|
||||||
|
GUI app ("My Backups"), the menu templates, and the account hooks, mirroring
|
||||||
|
apiscp-kopia verb-for-verb where it makes sense.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Scaffold and Layer 1 engine. The repository tools, restore tool, Layer 2 panel
|
||||||
|
integration, hooks, uninstall, and the full reference are being built out next,
|
||||||
|
tracking the apiscp-kopia feature set.
|
||||||
75
etc/apiscp-borg.config.example
Normal file
75
etc/apiscp-borg.config.example
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# apiscp-borg configuration. Copy to /etc/apiscp-borg/config (root-owned, 0600:
|
||||||
|
# it holds the repository passphrase). All keys have sane defaults so the engine
|
||||||
|
# also runs on a stock ApisCP box once BORG_REPO and BORG_PASSPHRASE are set.
|
||||||
|
#
|
||||||
|
# This file is sourced by POSIX sh. Quote values; do not add spaces around '='.
|
||||||
|
|
||||||
|
# --- repository ---------------------------------------------------------------
|
||||||
|
# Borg repository location. Either a local path or an ssh URL, e.g.:
|
||||||
|
# BORG_REPO="/mnt/backups/apiscp-borg"
|
||||||
|
# BORG_REPO="ssh://borg@backup.example.net:22/./apiscp-borg"
|
||||||
|
BORG_REPO=""
|
||||||
|
|
||||||
|
# Repository passphrase. Borg reads BORG_PASSPHRASE from the environment; the
|
||||||
|
# engine exports whatever is set here. Keep this file 0600 and root-owned.
|
||||||
|
# For recoverability, back it up off the machine (borg-apiscp-repo backup-key).
|
||||||
|
BORG_PASSPHRASE=""
|
||||||
|
|
||||||
|
# Encryption mode used when CREATING a new repository (repokey keeps the key in
|
||||||
|
# the repo so only the passphrase is needed to recover). Other modes:
|
||||||
|
# repokey-blake2 (default), keyfile-blake2 (key stored under ~/.config/borg).
|
||||||
|
BORG_ENCRYPTION="repokey-blake2"
|
||||||
|
|
||||||
|
# Extra SSH options for ssh:// repositories (e.g. a dedicated key). Passed to
|
||||||
|
# borg via BORG_RSH.
|
||||||
|
BORG_RSH="ssh -o BatchMode=yes"
|
||||||
|
|
||||||
|
# Where the one-time key/passphrase backup is written (store a copy off-machine).
|
||||||
|
KEY_BACKUP_DIR="/root/apiscp-borg-keys"
|
||||||
|
|
||||||
|
# --- what to back up ----------------------------------------------------------
|
||||||
|
# "all" (default), "none", or a comma-separated list of site ids (site1,site3).
|
||||||
|
BACKUP_SITES="all"
|
||||||
|
# Back up the shared system paths (1, default) or skip them (0).
|
||||||
|
BACKUP_SYSTEM="1"
|
||||||
|
# Dump and archive per-site databases via ApisCP's site-context export (1) or
|
||||||
|
# skip (0).
|
||||||
|
BACKUP_DATABASES="1"
|
||||||
|
# Extra absolute paths to back up, comma or space separated (default none).
|
||||||
|
BACKUP_PATHS=""
|
||||||
|
# Shared system paths and globs (space separated).
|
||||||
|
SYSTEM_PATHS="/etc /opt"
|
||||||
|
SYSTEM_GLOBS="/var/log/mailer_table* /var/lib/mysql/mysql-grants* /var/lib/pgsql/*/backups /root/apnscp* /root/license*"
|
||||||
|
# Comma-separated Borg exclude patterns (globs). The in-site restore staging dir
|
||||||
|
# /.borg-restore is always excluded.
|
||||||
|
BACKUP_EXCLUDES=""
|
||||||
|
|
||||||
|
# --- retention (borg prune, applied after each run) ---------------------------
|
||||||
|
# Blank means "no limit" for that bucket. Applied per site archive prefix.
|
||||||
|
RETENTION_KEEP_WITHIN=""
|
||||||
|
RETENTION_KEEP_DAILY=""
|
||||||
|
RETENTION_KEEP_WEEKLY=""
|
||||||
|
RETENTION_KEEP_MONTHLY=""
|
||||||
|
RETENTION_KEEP_ANNUAL=""
|
||||||
|
|
||||||
|
# --- schedule -----------------------------------------------------------------
|
||||||
|
# HH:MM for a daily run, or a full systemd OnCalendar expression. Managed via
|
||||||
|
# the panel or borg-apiscp-repo set-schedule (writes a timer drop-in).
|
||||||
|
BACKUP_SCHEDULE="03:30"
|
||||||
|
|
||||||
|
# --- notifications ------------------------------------------------------------
|
||||||
|
# Email a report of each run. NOTIFY_EMAIL empty disables it. NOTIFY_ON is
|
||||||
|
# "failure" (default), "always", or "never". NOTIFY_FROM overrides the sender.
|
||||||
|
NOTIFY_EMAIL=""
|
||||||
|
NOTIFY_ON="failure"
|
||||||
|
NOTIFY_FROM=""
|
||||||
|
|
||||||
|
# --- engine internals ---------------------------------------------------------
|
||||||
|
VIRTBASE="/home/virtual"
|
||||||
|
STAGE="/var/lib/apiscp-borg"
|
||||||
|
METRICS_DIR=""
|
||||||
|
RUN_DB_EXPORT="1"
|
||||||
|
APNSCP_CMD="/usr/local/apnscp/bin/cmd"
|
||||||
|
# Optionally also capture ACL/xattr text sidecars (Borg already preserves them
|
||||||
|
# natively, so this is off by default).
|
||||||
|
CAPTURE_META_SIDECAR="0"
|
||||||
70
install.sh
Normal file
70
install.sh
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
#!/bin/sh
|
||||||
|
#
|
||||||
|
# install.sh: install the apiscp-borg Layer 1 engine (backup + timer).
|
||||||
|
#
|
||||||
|
# Idempotent. Run as root on an ApisCP server. It:
|
||||||
|
# - installs the shared library under /usr/local/lib/apiscp-borg
|
||||||
|
# - installs bin/ scripts into /usr/local/bin
|
||||||
|
# - installs the systemd service + timer
|
||||||
|
# - seeds /etc/apiscp-borg/config from the example (never overwrites yours)
|
||||||
|
#
|
||||||
|
# It does NOT install borg itself (install it with your package manager:
|
||||||
|
# `dnf install borgbackup` / `apt install borgbackup`), enable the timer, or
|
||||||
|
# create the Borg repository. Do those once you have reviewed the config.
|
||||||
|
#
|
||||||
|
# Layer 2 (the ApisCP panel module + GUIs + hooks) installs separately via
|
||||||
|
# install-layer2.sh once that milestone lands. See docs/DESIGN.md.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
-h|--help) sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||||
|
*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ "$(id -u)" = 0 ] || { echo "run as root" >&2; exit 1; }
|
||||||
|
SRC=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
|
||||||
|
command -v borg >/dev/null 2>&1 || echo "WARNING: borg not found on PATH; install borgbackup before running the engine" >&2
|
||||||
|
|
||||||
|
LIBDIR=/usr/local/lib/apiscp-borg
|
||||||
|
BINDIR=/usr/local/bin
|
||||||
|
UNITDIR=/etc/systemd/system
|
||||||
|
CFGDIR=/etc/apiscp-borg
|
||||||
|
|
||||||
|
echo "installing library -> $LIBDIR"
|
||||||
|
mkdir -p "$LIBDIR"
|
||||||
|
install -m 0644 "$SRC/lib/apiscp-borg-common.sh" "$LIBDIR/apiscp-borg-common.sh"
|
||||||
|
|
||||||
|
echo "installing binaries -> $BINDIR"
|
||||||
|
for b in borg-apiscp-backup borg-apiscp-repo borg-apiscp-restore; do
|
||||||
|
[ -f "$SRC/bin/$b" ] && install -m 0755 "$SRC/bin/$b" "$BINDIR/$b"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "installing systemd units -> $UNITDIR"
|
||||||
|
install -m 0644 "$SRC/systemd/borg-apiscp-backup.service" "$UNITDIR/borg-apiscp-backup.service"
|
||||||
|
install -m 0644 "$SRC/systemd/borg-apiscp-backup.timer" "$UNITDIR/borg-apiscp-backup.timer"
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
echo "seeding config -> $CFGDIR/config"
|
||||||
|
mkdir -p "$CFGDIR"
|
||||||
|
if [ -f "$CFGDIR/config" ]; then
|
||||||
|
echo " $CFGDIR/config exists; leaving it untouched"
|
||||||
|
else
|
||||||
|
install -m 0600 "$SRC/etc/apiscp-borg.config.example" "$CFGDIR/config"
|
||||||
|
echo " seeded from example (0600, root); set BORG_REPO and BORG_PASSPHRASE before enabling"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<'EOF'
|
||||||
|
|
||||||
|
Installed. Next steps:
|
||||||
|
1. Install borgbackup if not already present.
|
||||||
|
2. Set BORG_REPO and BORG_PASSPHRASE in /etc/apiscp-borg/config.
|
||||||
|
3. Initialise the repository: borg-apiscp-repo init (once the repo tool lands)
|
||||||
|
or for now: BORG_REPO=... borg init --encryption=repokey-blake2 ::
|
||||||
|
4. Back up the passphrase/key OFF this machine.
|
||||||
|
5. Test a run: borg-apiscp-backup ; echo "exit=$?"
|
||||||
|
6. Enable nightly: systemctl enable --now borg-apiscp-backup.timer
|
||||||
|
EOF
|
||||||
85
lib/apiscp-borg-common.sh
Normal file
85
lib/apiscp-borg-common.sh
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
# shellcheck shell=sh
|
||||||
|
# apiscp-borg-common.sh
|
||||||
|
#
|
||||||
|
# Shared routines for the apiscp-borg engine and tools. POSIX sh, no bashisms,
|
||||||
|
# so it runs under dash/ash as well as bash.
|
||||||
|
#
|
||||||
|
# Unlike apiscp-kopia, Borg preserves POSIX ACLs and extended attributes on its
|
||||||
|
# own, so there is no mandatory ACL/xattr sidecar here. Optional sidecar helpers
|
||||||
|
# are provided (off by default) purely as belt-and-braces for operators who want
|
||||||
|
# a human-diffable record alongside the archive.
|
||||||
|
|
||||||
|
: "${APISCP_BORG_LOG_TAG:=apiscp-borg}"
|
||||||
|
|
||||||
|
akp_log() { printf '%s %s\n' "$(_akp_now)" "$*" >&2; command -v logger >/dev/null 2>&1 && logger -t "$APISCP_BORG_LOG_TAG" -- "$*" || true; }
|
||||||
|
akp_warn() { akp_log "WARN: $*"; }
|
||||||
|
akp_die() { akp_log "FATAL: $*"; exit 1; }
|
||||||
|
|
||||||
|
_akp_now() { date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null || date; }
|
||||||
|
|
||||||
|
# Turn an arbitrary path into a filesystem/archive-safe slug.
|
||||||
|
akp_slug() {
|
||||||
|
printf '%s' "$1" | sed -e 's#^/##' -e 's#[^A-Za-z0-9._-]#_#g'
|
||||||
|
}
|
||||||
|
|
||||||
|
# akp_borg -- run borg with the configured repository and passphrase in the
|
||||||
|
# environment. BORG_REPO and BORG_PASSPHRASE (or BORG_PASSCOMMAND) must already
|
||||||
|
# be exported by the caller (the config file sets them). Kept as a wrapper so a
|
||||||
|
# future transport tweak is one place.
|
||||||
|
akp_borg() {
|
||||||
|
borg "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# akp_send_mail TO SUBJECT BODY [FROM]: send a plain-text email via the local
|
||||||
|
# sendmail. Best-effort; returns non-zero if no sendmail is found or send fails.
|
||||||
|
akp_send_mail() {
|
||||||
|
_am_to="$1"; _am_subj="$2"; _am_body="$3"
|
||||||
|
_am_from="${4:-root@$(hostname 2>/dev/null || echo localhost)}"
|
||||||
|
[ -n "$_am_to" ] || return 1
|
||||||
|
_am_snd=""
|
||||||
|
for _c in /usr/sbin/sendmail /usr/lib/sendmail sendmail; do
|
||||||
|
command -v "$_c" >/dev/null 2>&1 && { _am_snd="$_c"; break; }
|
||||||
|
done
|
||||||
|
[ -n "$_am_snd" ] || { akp_warn "no sendmail binary; cannot send mail"; return 1; }
|
||||||
|
printf 'From: %s\nTo: %s\nSubject: %s\nContent-Type: text/plain; charset=UTF-8\n\n%s\n' \
|
||||||
|
"$_am_from" "$_am_to" "$_am_subj" "$_am_body" | "$_am_snd" -t -i 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- site helpers (shared with the restore/repo tools) -----------------------
|
||||||
|
|
||||||
|
# akp_site_domain SITE: echo the site's primary domain from its ApisCP siteinfo.
|
||||||
|
akp_site_domain() {
|
||||||
|
_sd_si="${VIRTBASE:-/home/virtual}/$1/info/current/siteinfo"
|
||||||
|
[ -r "$_sd_si" ] || return 0
|
||||||
|
sed -n 's/^[[:space:]]*domain[[:space:]]*=[[:space:]]*\([^[:space:]]*\).*/\1/p' "$_sd_si" | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# akp_site_fst SITE: echo the site's fst root, honouring VIRTBASE.
|
||||||
|
akp_site_fst() { printf '%s/%s/fst' "${VIRTBASE:-/home/virtual}" "$1"; }
|
||||||
|
|
||||||
|
# akp_site_admin_user SITE: the site's admin_user from its siteinfo, or empty.
|
||||||
|
akp_site_admin_user() {
|
||||||
|
_asau_si="${VIRTBASE:-/home/virtual}/$1/info/current/siteinfo"
|
||||||
|
[ -r "$_asau_si" ] || return 0
|
||||||
|
sed -n 's/^[[:space:]]*admin_user[[:space:]]*=[[:space:]]*\([^[:space:]]*\).*/\1/p' "$_asau_si" | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# akp_owner_chown SITE PATH: recursively chown PATH so the site owner can access
|
||||||
|
# it. Site users are namespaced (not in the host passwd), so take ownership from
|
||||||
|
# the admin user's own home directory as a reference, falling back to the numeric
|
||||||
|
# uid:gid parsed from the site's own passwd.
|
||||||
|
akp_owner_chown() {
|
||||||
|
_oc_site="$1"; _oc_path="$2"
|
||||||
|
[ -d "$_oc_path" ] || return 1
|
||||||
|
_oc_fst=$(akp_site_fst "$_oc_site")
|
||||||
|
_oc_admin=$(akp_site_admin_user "$_oc_site")
|
||||||
|
if [ -n "$_oc_admin" ] && [ -d "$_oc_fst/home/$_oc_admin" ]; then
|
||||||
|
chown -R --reference="$_oc_fst/home/$_oc_admin" "$_oc_path" 2>/dev/null && return 0
|
||||||
|
fi
|
||||||
|
if [ -n "$_oc_admin" ] && [ -r "$_oc_fst/etc/passwd" ]; then
|
||||||
|
_oc_ug=$(awk -F: -v u="$_oc_admin" '$1==u {print $3":"$4; exit}' "$_oc_fst/etc/passwd")
|
||||||
|
[ -n "$_oc_ug" ] && chown -R "$_oc_ug" "$_oc_path" 2>/dev/null && return 0
|
||||||
|
fi
|
||||||
|
akp_warn "owner_chown: could not determine owner for $_oc_site; leaving $_oc_path root-owned"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
13
systemd/borg-apiscp-backup.service
Normal file
13
systemd/borg-apiscp-backup.service
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
[Unit]
|
||||||
|
Description=apiscp-borg backup run (ACL/xattr-aware BorgBackup engine for ApisCP)
|
||||||
|
Documentation=https://git.discworld.casa/laurence/apiscp-borg
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
Nice=10
|
||||||
|
IOSchedulingClass=idle
|
||||||
|
ExecStart=/usr/local/bin/borg-apiscp-backup
|
||||||
|
# The engine holds its own lock and writes metrics on exit.
|
||||||
|
TimeoutStartSec=0
|
||||||
11
systemd/borg-apiscp-backup.timer
Normal file
11
systemd/borg-apiscp-backup.timer
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[Unit]
|
||||||
|
Description=apiscp-borg nightly backup timer
|
||||||
|
Documentation=https://git.discworld.casa/laurence/apiscp-borg
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 03:30:00
|
||||||
|
RandomizedDelaySec=1800
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
Loading…
Add table
Add a link
Reference in a new issue