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:
Laurence Horrocks-Barlow 2026-07-24 22:33:37 +01:00
commit 2f0d93a0ae
10 changed files with 743 additions and 0 deletions

85
lib/apiscp-borg-common.sh Normal file
View 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
}