commit 2f0d93a0ae6f2296c65560f13a3bd353c2746263 Author: Laurence Horrocks-Barlow Date: Fri Jul 24 22:33:37 2026 +0100 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..262a0a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Local editor / OS noise +*.swp +*~ +.DS_Store + +# Never commit secrets or local state +etc/apiscp-borg.config +*.key +*.passphrase diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a9fa83f --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e8b58eb --- /dev/null +++ b/README.md @@ -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`. diff --git a/bin/borg-apiscp-backup b/bin/borg-apiscp-backup new file mode 100644 index 0000000..7b26a6a --- /dev/null +++ b/bin/borg-apiscp-backup @@ -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 ] diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..f9f4642 --- /dev/null +++ b/docs/DESIGN.md @@ -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 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--{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. diff --git a/etc/apiscp-borg.config.example b/etc/apiscp-borg.config.example new file mode 100644 index 0000000..9a0c925 --- /dev/null +++ b/etc/apiscp-borg.config.example @@ -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" diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..c3fb6a2 --- /dev/null +++ b/install.sh @@ -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 diff --git a/lib/apiscp-borg-common.sh b/lib/apiscp-borg-common.sh new file mode 100644 index 0000000..c27b8c6 --- /dev/null +++ b/lib/apiscp-borg-common.sh @@ -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 +} diff --git a/systemd/borg-apiscp-backup.service b/systemd/borg-apiscp-backup.service new file mode 100644 index 0000000..cc89b9d --- /dev/null +++ b/systemd/borg-apiscp-backup.service @@ -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 diff --git a/systemd/borg-apiscp-backup.timer b/systemd/borg-apiscp-backup.timer new file mode 100644 index 0000000..1e2956c --- /dev/null +++ b/systemd/borg-apiscp-backup.timer @@ -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