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

9
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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.

View 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
View 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
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
}

View 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

View 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