- Engine: same targeted-run fix as apiscp-kopia (env BACKUP_SITES/SYSTEM/PATHS/ DATABASES now override the sourced config, so `run --site` scopes correctly instead of backing up every site). - docs/REFERENCE.md: exhaustive reference (every command, verb, config key, installer, automation, both GUIs, security model, recovery), plus a README Documentation section.
52 KiB
apiscp-borg reference
Exhaustive reference for every command, verb, configuration key, installer, and
automation shipped in this repository. For the design rationale, and for how
Borg's own metadata handling changes the approach taken by the sibling
apiscp-kopia project, see docs/DESIGN.md.
1. Overview
apiscp-borg is a backup engine for ApisCP built on top of
BorgBackup. Unlike kopia, Borg preserves POSIX
ACLs and extended attributes natively on Linux, so no ACL/xattr sidecar
mechanism is needed to protect the permission data ApisCP encodes in those
mechanisms: borg create captures them and borg extract restores them on its
own. The plugin's value is instead: running ApisCP's own per-site database
export before archiving so the logical .sql dumps are current, archiving
each site as its own named set of Borg archives so single-site restore and
per-site retention are possible, and wiring retention, maintenance, integrity
checking, scheduling, notifications, one-time key backup, and both a
CLI and a native ApisCP panel integration around the borg binary. It ships
as two independent layers: a standalone POSIX-sh engine (Layer 1, three
binaries plus a systemd timer, with zero ApisCP coupling) and a native ApisCP
integration (Layer 2, a module plus two GUI apps installed into upgrade-safe,
git-ignored locations). Layer 2 provides two editions of the panel:
- Appliance-admin edition ("Borg Backups"): full repository configuration,
backup selection, scheduling, retention (prune), maintenance (compact),
integrity checking, notifications, one-time key backup, and restore (site,
account, system, or database) for any site on the box.
PRIVILEGE_ADMIN. - Site-owner edition ("My Borg Backups"): a scoped self-service panel for
a single signed-in site owner to restore their own files, databases, or
whole account, and to trigger a backup of their own site on demand. It never
accepts a site identifier from the caller; the site is always derived from
the authenticated session.
PRIVILEGE_SITE.
Borg's repository model is simpler than kopia's: there are no backend
"types", no repository server, and no SFTP transport. A repository is just a
location (BORG_REPO, a local path or an ssh:// URL), a BORG_PASSPHRASE,
a BORG_ENCRYPTION mode (repokey-blake2 by default, so the encryption key
lives inside the repository and only the passphrase is needed to recover it),
and an optional BORG_RSH for SSH transport options. There are no tagged
snapshots either: Borg has named archives, and this engine names them with a
structured prefix (siteN-shadow, siteN-info, siteN-db, _system,
_custom-<slug>, each suffixed with Borg's {now} timestamp placeholder) so
per-site operations are glob-addressable. Restore in this release always uses
the newest archive of a given prefix: there is no point-in-time archive
selection and no browse/subpath picker in the panel (a current limitation,
noted throughout this document and in the GUI itself).
2. Installation
install.sh (Layer 1: the engine)
Installs the standalone engine. Must be run as root. Idempotent.
sudo sh install.sh
Unlike apiscp-kopia's installer, this script does not install borg
itself: it only checks whether borg is on PATH and prints a warning if it
is not. Install borgbackup yourself first (dnf install borgbackup /
apt install borgbackup).
Flags:
| Flag | Effect |
|---|---|
| (none) | Installs the library, binaries, and systemd units; seeds the config. |
-h, --help |
Prints the header usage comment and exits 0. |
What it installs, in order:
| Item | Source | Destination | Mode |
|---|---|---|---|
| shared library | lib/apiscp-borg-common.sh |
/usr/local/lib/apiscp-borg/apiscp-borg-common.sh |
0644 |
| engine binary | bin/borg-apiscp-backup |
/usr/local/bin/borg-apiscp-backup |
0755 |
| repo binary | bin/borg-apiscp-repo |
/usr/local/bin/borg-apiscp-repo |
0755 |
| restore binary | bin/borg-apiscp-restore |
/usr/local/bin/borg-apiscp-restore |
0755 |
| systemd service | systemd/borg-apiscp-backup.service |
/etc/systemd/system/borg-apiscp-backup.service |
0644 |
| systemd timer | systemd/borg-apiscp-backup.timer |
/etc/systemd/system/borg-apiscp-backup.timer |
0644 |
| config seed | etc/apiscp-borg.config.example |
/etc/apiscp-borg/config |
0600, only if absent (never overwrites an existing config) |
systemctl daemon-reload is run after the units are installed. The script
does not enable the timer, install borg, or create/connect the Borg
repository; it prints the manual next steps (install borgbackup, set
BORG_REPO/BORG_PASSPHRASE, initialise the repository, back up the
passphrase/key off-machine, one test run, systemctl enable --now borg-apiscp-backup.timer).
install-layer2.sh (Layer 2: native ApisCP integration)
Installs the panel module, both GUI apps, both menu links, the account
lifecycle hooks, and a scoped sudoers file. Run as root, after install.sh.
Idempotent. Respects CP_ROOT (default /usr/local/apnscp) and PANEL_USER
(default apnscp).
| Step | Source | Destination | Notes |
|---|---|---|---|
| Module | src/modules/borg.php |
$CP_ROOT/lib/modules/surrogates/borg.php |
git-ignored by ApisCP (surrogates/*), survives upcp |
| Admin GUI app | src/apps/borg/ (borg.php, application.yml, views/index.blade.php) |
$CP_ROOT/config/custom/apps/borg/ |
whole tree ignored |
| Site-owner GUI app | src/apps/myborgbackups/ |
$CP_ROOT/config/custom/apps/myborgbackups/ |
whole tree ignored |
| Admin menu link | src/templates/admin.php |
$CP_ROOT/config/custom/templates/admin.php |
only written if the file does not already exist; if it exists and already references /apps/borg, left untouched; if it exists, does not reference /apps/borg, and contains a closing ?>, a warning is printed and nothing is appended; otherwise the create_link() call is appended |
| Site menu link | src/templates/site.php |
$CP_ROOT/config/custom/templates/site.php |
same append-not-clobber logic, keyed on /apps/myborgbackups |
| Account hooks | src/hooks/{addDomain,suspendDomain,deleteDomain}.sh |
$CP_ROOT/config/custom/hooks/<event>.sh |
only installed if the destination does not exist, or exists but does not already contain the string apiscp-borg (an operator's own hook is left untouched) |
| Sudoers | generated inline | /etc/sudoers.d/apiscp-borg |
0440 root:root, validated with visudo -cf before install; installation is skipped with a warning if validation fails |
After installing the sudoers file, the script forces the engine config to be
root-owned (chown root:root + chmod 0600 on /etc/apiscp-borg/config if
it exists), then restarts the panel (systemctl restart apnscp) so the new
GUI apps and menu entries are picked up; the module itself needs no restart
and is picked up on the next CLI/API call. Site owners see the "My Borg
Backups" menu entry after their next login (the menu is session-cached).
Upgrade-safe locations. Every Layer 2 destination is one ApisCP
git-ignores, so a git pull / upcp upgrade never touches it:
lib/modules/surrogates/*, and the entirety of config/custom/ (via its own
.gitignore of * plus !.gitignore).
3. Uninstall
uninstall.sh, run as root, idempotent.
sudo sh uninstall.sh # default: keep config, staging, key backups
sudo sh uninstall.sh --purge # also delete config, staging dir, key backup dir
Always removed (both modes):
- Layer 2: the module surrogate, both GUI app directories (
borgandmyborgbackups), the account hooks (only the ones containing the stringapiscp-borg), the scoped sudoers file, and theapiscp-borglines stripped out ofadmin.php/site.php(the template file itself is deleted only if nothing but<?phpand whitespace remains after stripping). The panel is restarted afterwards. - Layer 1: the timer is disabled and stopped, the service is stopped, both
systemd unit files and any
borg-apiscp-backup.timer.dschedule drop-in are removed,systemctl daemon-reloadruns, the three binaries and the shared library directory are removed, and the Prometheus textfile metric (apiscp-borg.prom) is deleted from any of the well-known collector directories.
Kept by default (i.e. not removed unless --purge):
/etc/apiscp-borg/config(holds the repository passphrase)/var/lib/apiscp-borg(the staging area; also where the deleted-accounts audit log lives)- the key backup directory (
KEY_BACKUP_DIR, read out of the config before it would be deleted)
With --purge, the config directory and staging directory are deleted; the
key backup directory is deliberately not auto-deleted even under
--purge (its path is only printed, with an explicit rm -rf suggestion),
because it may be the only offline copy of the repository passphrase/key.
Guarantee, always true, in both modes: the Borg repository and every
archive in it are never touched by uninstall.sh, and the borg binary
itself is never removed. Uninstalling apiscp-borg does not affect your
ability to recover backups with plain borg, or to reinstall and pick up
where you left off.
4. Configuration reference
Config lives at /etc/apiscp-borg/config, POSIX-sh KEY=value syntax (no
spaces around =), sourced directly by the three binaries. Every key has a
built-in default so the engine runs unconfigured on a stock ApisCP box (other
than BORG_REPO, which is required before any real operation).
etc/apiscp-borg.config.example documents the same defaults and is seeded
(never overwritten) by install.sh.
CONFIG_ALLOWED in bin/borg-apiscp-repo (config-set) is the authoritative
whitelist of keys settable through the CLI/GUI (the module's
Borg_Module_Surrogate::ALLOWED_KEYS mirrors it exactly). A config-set /
set_option call for any other key is rejected. Several engine-internals keys
(VIRTBASE, STAGE, SYSTEM_PATHS, SYSTEM_GLOBS, APNSCP_CMD,
APNSCP_ROOT, DB_EXPORT_CMD, LOCK_FILE) are read from the config file if
present but are not in CONFIG_ALLOWED; they can only be changed by
editing the config file directly, not via config-set, set_option, or
either GUI.
Repository
| Key | Meaning | Default | Example |
|---|---|---|---|
BORG_REPO |
Repository location: a local path or an ssh:// URL |
(none, required) | ssh://borg@backup.example.net:22/./apiscp-borg |
BORG_PASSPHRASE |
Repository passphrase, exported into the environment for every borg invocation |
(none) | generated via gen-passphrase |
BORG_ENCRYPTION |
Encryption mode used only when creating a new repository | repokey-blake2 |
keyfile-blake2 |
BORG_RSH |
Extra SSH options passed to borg via BORG_RSH, for ssh:// repositories |
ssh -o BatchMode=yes |
ssh -o BatchMode=yes -i /root/.ssh/borg_key |
BORG_COMPRESSION |
Compression algorithm passed to borg create --compression |
zstd |
lz4, zstd,15, none |
KEY_BACKUP_DIR |
Where backup-key writes the one-time passphrase/key export |
/root/apiscp-borg-keys |
(none) |
repokey-blake2 (the default) stores the encryption key inside the
repository itself, so only the passphrase is needed to recover it.
keyfile-blake2 stores the key under ~/.config/borg on the machine that
created the repository and must be backed up separately (borg key export,
handled automatically by backup-key/key-bundle).
What to back up
| Key | Meaning | Default | Example |
|---|---|---|---|
BACKUP_SITES |
all, none, or a comma-separated list of site ids |
all |
site1,site3 |
BACKUP_SYSTEM |
Back up the shared system paths as well as per-site data: 1 or 0 |
1 |
0 |
BACKUP_DATABASES |
Dump every site's databases via ApisCP's native per-site export and archive them: 1 or 0 |
1 |
0 |
BACKUP_PATHS |
Extra absolute paths to back up, comma or space separated | (empty) | /srv/extra,/opt/thing |
SYSTEM_PATHS |
Shared, non-per-site paths, space separated (not settable via CLI/GUI) | /etc /opt |
(as default) |
SYSTEM_GLOBS |
Shell globs (space separated) expanded at run time; non-matching globs are skipped, not passed literally to borg (not settable via CLI/GUI) |
/var/log/mailer_table* /var/lib/mysql/mysql-grants* /var/lib/pgsql/*/backups /root/apnscp* /root/license* |
(as default) |
CAPTURE_META_SIDECAR |
Whitelisted config key reserved for an optional ACL/xattr text sidecar capture, off by default; Borg already preserves ACLs/xattrs natively in every archive it creates, and no sidecar-capture logic is implemented in the current engine regardless of this setting | 0 |
1 |
Retention (borg prune)
Applied per archive-name prefix (each site's shadow, info, and db
archives separately, plus _system-* and _custom-*), both automatically
after every backup run and on demand via the prune verb/button. Any unset
or blank value means no limit for that bucket. There is no KEEP_LATEST and
no KEEP_HOURLY bucket (unlike kopia).
| Key | Meaning | Default | Example |
|---|---|---|---|
RETENTION_KEEP_WITHIN |
Keep every archive created within this duration (Borg's --keep-within, a number plus H/d/w/m/y) |
(none / unlimited) | 7d |
RETENTION_KEEP_DAILY |
Keep 1 per day for this many days (--keep-daily) |
(none / unlimited) | 30 |
RETENTION_KEEP_WEEKLY |
Keep 1 per week for this many weeks (--keep-weekly) |
(none / unlimited) | 52 (1 year) |
RETENTION_KEEP_MONTHLY |
Keep 1 per month for this many months (--keep-monthly) |
(none / unlimited) | 24 (2 years) |
RETENTION_KEEP_ANNUAL |
Keep 1 per year for this many years (Borg's --keep-yearly) |
(none / unlimited) | 5 |
Excludes
| Key | Meaning | Default | Example |
|---|---|---|---|
BACKUP_EXCLUDES |
borg create --exclude glob patterns, comma-separated on disk (one per line in the GUI textarea) |
(empty) | *.log,cache/,node_modules/ |
*/.borg-restore and /.borg-restore are always excluded (hardcoded in
both create_archive/_create_with_excludes), so site-owner self-service
restores staged under that path are never recaptured into a subsequent
archive.
Schedule
| Key | Meaning | Default | Example |
|---|---|---|---|
BACKUP_SCHEDULE |
Time to run backups: HH:MM for a daily time, or a full systemd OnCalendar expression |
03:30 |
Mon *-*-* 03:30:00 |
Notifications
| Key | Meaning | Default | Example |
|---|---|---|---|
NOTIFY_EMAIL |
Recipient address; empty disables email notification entirely | (empty) | ops@example.com |
NOTIFY_ON |
failure (default, only email on a failed/incomplete run), always, or never |
failure |
always |
NOTIFY_FROM |
Override the From: address |
root@$(hostname) |
backups@example.com |
Engine internals
| Key | Meaning | Default | Settable via CLI/GUI? |
|---|---|---|---|
VIRTBASE |
ApisCP virtual host base; sites are $VIRTBASE/site<N> |
/home/virtual |
yes |
STAGE |
Reserved working area for the engine (not exercised by any active code path in this release) | /var/lib/apiscp-borg |
yes |
METRICS_DIR |
Prometheus node_exporter textfile collector directory; blank autodetects common locations, and metrics are skipped if none is found | (empty / autodetect) | yes |
RUN_DB_EXPORT |
Run ApisCP's per-site database export (backup_dbs.php) before archiving: 1 or 0 |
1 |
yes |
DB_EXPORT_CMD |
Path to backup_dbs.php |
$APNSCP_ROOT/bin/scripts/backup_dbs.php |
no |
APNSCP_CMD |
ApisCP CLI binary, used for native per-site database verbs (list_databases/export/import) |
/usr/local/apnscp/bin/cmd |
no |
APNSCP_ROOT |
Path to the ApisCP installation | /usr/local/apnscp |
no |
LOCK_FILE |
Single-instance lock file guarding against overlapping backup runs (via flock, if available) |
/run/apiscp-borg.lock |
no |
Two further variables are read only via the environment, not the config file
whitelist: APISCP_BORG_LIB (override the shared-library search path) and
APISCP_BORG_CONFIG (override the config file path itself, default
/etc/apiscp-borg/config), plus APISCP_BORG_LOG_TAG (the logger tag used
by akp_log, default apiscp-borg) and APISCP_BORG_DELETED_LOG (used only
by the deleteDomain hook, default /var/lib/apiscp-borg/deleted-accounts.log).
BORG_RELOCATED_REPO_ACCESS_IS_OK is exported by the engine (default no,
overridable via the real environment) so an unattended run does not block on
Borg's interactive "repository moved" prompt.
5. CLI reference (Layer 1)
All three binaries source lib/apiscp-borg-common.sh (search order:
$APISCP_BORG_LIB, ../lib/ relative to the binary,
/usr/local/lib/apiscp-borg/, /usr/lib/apiscp-borg/) then
$APISCP_BORG_CONFIG (default /etc/apiscp-borg/config). Every subcommand
below matches the code exactly.
borg-apiscp-backup
Takes no subcommands; running it (with no arguments) performs one full backup
run and exits 0 if every archive was created, 1 if any failed. Driven by the
systemd timer/service, or launched detached by borg-apiscp-repo run.
Sequence on every run:
- Acquire
$LOCK_FILEviaflock(non-blocking; a second concurrent run aborts immediately) ifflockis available. - If
RUN_DB_EXPORT=1, run$DB_EXPORT_CMD(ApisCP'sbackup_dbs.php) so per-site.sqldumps are current before they are archived. - For every
$VIRTBASE/site<N>directory (skipping any not selected byBACKUP_SITES):- archive
shadow(if present) assite<N>-shadow-{now}; - archive
info(if present) assite<N>-info-{now}; - if
BACKUP_DATABASES=1: enumerate the site'smysqlandpgsqldatabases via$APNSCP_CMD -o json -d <domain> <engine>:list_databases, export each to a site-relative staging path (/tmp/.apiscp-borg-db/<engine>/<db>.sql, resolved inside the site'sfstnamespace by ApisCP), archive that staging tree from its real host location (<site>/fst/tmp/.apiscp-borg-db) assite<N>-db-{now}, then delete the staging tree from the site; - apply per-archive-prefix retention (
borg prune) forsite<N>-shadow-*,site<N>-info-*, andsite<N>-db-*right away, so one site's daily churn is pruned every run rather than waiting for a separate step.
- archive
- If
BACKUP_SYSTEM=1: expandSYSTEM_PATHSand matchingSYSTEM_GLOBSentries to those that actually exist, archive them together as_system-{now}, then prune_system-*. - If
BACKUP_PATHSis non-empty: for each existing custom path, archive it as_custom-<slug>-{now}(slug viaakp_slug), then prune_custom-<slug>-*. - Write Prometheus textfile metrics (always, even on failure or interrupt,
via an
EXIT/INT/TERMtrap) and send an email notification perNOTIFY_EMAIL/NOTIFY_ON/NOTIFY_FROM.
Every archive is created with borg create --compression "${BORG_COMPRESSION:-zstd}", plus --exclude '*/.borg-restore' --exclude '/.borg-restore' and one --exclude per BACKUP_EXCLUDES entry.
Metrics written to $METRICS_DIR/apiscp-borg.prom (or autodetected):
apiscp_borg_run_success, apiscp_borg_archives_ok,
apiscp_borg_archives_failed, apiscp_borg_duration_seconds,
apiscp_borg_db_export_success (1 ok / 0 failed / -1 skipped), and
apiscp_borg_last_run_timestamp_seconds.
borg-apiscp-repo
Manages the repository and everything that is not a data restore.
| Subcommand | Syntax | Description |
|---|---|---|
status |
borg-apiscp-repo status |
Print repository: $BORG_REPO followed by borg info (first 30 lines, indented). |
init |
borg-apiscp-repo init |
Create (initialise) the repository at BORG_REPO with BORG_ENCRYPTION. Requires BORG_PASSPHRASE already set. Idempotent: if borg info already succeeds against the repository, logs and returns 0 without re-initialising. |
check-access |
borg-apiscp-repo check-access |
Print ok if the repository is reachable and the passphrase is correct, else error: cannot access .... Always exits 0 so a UI can read the message. |
gen-passphrase |
borg-apiscp-repo gen-passphrase |
Generate a strong random passphrase (openssl rand -base64 30, or /dev/urandom fallback), store it as BORG_PASSPHRASE via config-set, and print only the passphrase to stdout. |
key-status |
borg-apiscp-repo key-status |
Print yes if the one-time key/passphrase backup sentinel exists at $KEY_BACKUP_DIR/.apiscp-borg-key-backed-up, else no. |
backup-key |
borg-apiscp-repo backup-key [DEST] [--force] |
Export repository.passphrase (if BORG_PASSPHRASE is set), repository.key (via borg key export, which is a no-op warning for repokey repositories since the key already lives in the repository), and a README.txt to DEST (default $KEY_BACKUP_DIR), all 0600. Refuses to redo (logs and returns 0) unless --force. |
key-bundle |
borg-apiscp-repo key-bundle |
Print a self-contained recovery bundle (instructions, the plaintext passphrase, the repository location, and an exported key via borg key export :: /dev/stdout) to stdout. For the GUI's one-time post-creation display/download. |
config-get |
borg-apiscp-repo config-get |
Print every KEY=VALUE line from the config file with surrounding quotes stripped (comments and malformed lines skipped). |
config-set |
borg-apiscp-repo config-set KEY VALUE |
Set one config key, rejecting any key not in CONFIG_ALLOWED and any value containing a CR or LF. Value is stored single-quoted (shell-escaped) so the config, which is sourced by the engine, cannot be injected. Writes with mode 0600. |
sites |
borg-apiscp-repo sites |
List site<N>\t<domain> for every ApisCP site under $VIRTBASE (domain falls back to the site id if siteinfo cannot be read). |
run |
borg-apiscp-repo run [--site SITE | --path PATH] |
No args (or --all): systemctl start --no-block borg-apiscp-backup.service (returns immediately). --site SITE: launch a detached one-shot run scoped to just that site (BACKUP_SITES=SITE BACKUP_SYSTEM=0); SITE must match site[0-9]* or be the literal none. --path PATH: launch a detached one-shot backing up only that one absolute, existing, readable path (BACKUP_SITES=none BACKUP_SYSTEM=0 BACKUP_PATHS=PATH). Detached runs use systemd-run --collect if available, else a backgrounded env ... &. |
running |
borg-apiscp-repo running |
Print systemctl is-active borg-apiscp-backup.service (e.g. active, inactive). |
prune |
borg-apiscp-repo prune |
Apply retention (RETENTION_KEEP_*, numeric/duration values only) across every archive prefix: each site's shadow/info/db archives separately, plus _system-* and _custom-*. If no retention key is set, logs and does nothing. |
maintenance |
borg-apiscp-repo maintenance |
Run borg compact, detached (systemd-run --collect if available, else backgrounded). |
verify |
borg-apiscp-repo verify [--data] |
Run borg check (repository and archive consistency), detached. With --data, adds --verify-data to also read file contents back (slow, thorough); without it, only metadata is checked. |
set-schedule |
borg-apiscp-repo set-schedule [SPEC] |
Set the backup timer's OnCalendar. SPEC is HH:MM (validated 00-23:00-59, becomes *-*-* HH:MM:00) or a full OnCalendar expression (validated against an allowed character set); falls back to BACKUP_SCHEDULE if SPEC is omitted. Writes /etc/systemd/system/borg-apiscp-backup.timer.d/override.conf, reloads systemd, restarts the timer. Prints the resulting OnCalendar value. |
notify-test |
borg-apiscp-repo notify-test |
Send a test email to NOTIFY_EMAIL to confirm mail delivery; fails if NOTIFY_EMAIL is unset. |
list-databases |
borg-apiscp-repo list-databases SITE |
Print <engine>\t<db> (engine mysql or pgsql) for every database ApisCP currently knows for SITE, via native per-site enumeration (a host-level dump cannot see per-site databases). SITE must match site[0-9]*. |
(no args / -h/--help/help) |
Print the full header usage comment (via awk) and exit 0. |
|
| (anything else) | Print unknown command: <cmd> to stderr and exit 2. |
There is no connect, create, types, repo-info, validate-path,
browse, or apply-policy subcommand: those either do not apply to Borg's
simpler repository model, or (in the case of repo-info and validate-path)
are computed directly in the Layer 2 module without a root round trip (see
section 6).
borg-apiscp-restore
Restores data from a Borg archive. Borg stores archived paths relative
(leading / stripped) and preserves POSIX ACLs and extended attributes
natively as part of every archive, so restore is a plain borg extract with
--strip-components to rebase the archived path under a staging target: no
separate metadata-replay step is needed (that is the kopia engine's job, not
Borg's). Archive naming (from the engine): siteN-shadow-{now},
siteN-info-{now}, siteN-db-{now}, _system-{now},
_custom-<slug>-{now}. "Newest" means the most recent archive matching a
given prefix (borg list --glob-archives PREFIX-* --last 1).
| Subcommand | Syntax | Description |
|---|---|---|
list |
borg-apiscp-restore list [PREFIX] |
List archives, optionally filtered to PREFIX-* (e.g. site1), else every archive in the repository. |
list-json |
borg-apiscp-restore list-json PREFIX |
Print raw borg list --json --glob-archives PREFIX-*. PREFIX is required. Intended for the module/GUI to parse. |
site |
borg-apiscp-restore site SITE TARGET [--subpath P ...] |
Restore SITE's newest shadow and info archives into TARGET/{shadow,info}. With one or more --subpath, restores only those paths (relative to the site root, under shadow/) instead of the whole tree; each subpath is rejected if it starts with / or contains ... Fails if nothing was restored. |
account |
borg-apiscp-restore account SITE TARGET |
Restore a whole account: files (shadow+info, fails hard if none exist) plus the newest SITE-db archive as a tree into TARGET/databases (warns, does not fail, if no database archive exists). |
system |
borg-apiscp-restore system TARGET |
Restore the newest _system archive into TARGET via borg extract --numeric-ids. |
restore-db |
borg-apiscp-restore restore-db SITE ENGINE DB TARGET |
Non-destructive: extract ENGINE/DB.sql from the newest SITE-db archive into TARGET/DB.sql, print the restored path. ENGINE must be mysql or pgsql; DB must match [A-Za-z0-9_-]+. |
import-db |
borg-apiscp-restore import-db SITE ENGINE DB |
Destructive. Restores the newest dump of SITE's DB to a temp directory (there is no DUMPFILE argument: the newest archived dump is always the one imported), stages it inside the site's own fst tree, and imports it into the site's live database via $APNSCP_CMD -d <domain> <engine>:import, then removes the staged copy. |
owner-files |
borg-apiscp-restore owner-files SITE [--subpath P ...] |
Site-owner variant of site: restores into the site's own filesystem under /.borg-restore/files-<epoch>/, chowned to the site owner, and prints the in-site relative path. |
owner-account |
borg-apiscp-restore owner-account SITE |
Site-owner whole-account restore into /.borg-restore/account-<epoch>/ (files, plus databases under databases/), chowned to the owner. Prints the in-site relative path. |
owner-restore-db |
borg-apiscp-restore owner-restore-db SITE ENGINE DB |
Site-owner, non-destructive: restore the newest dump of the site's own database into /.borg-restore/db-<epoch>/DB.sql, chowned to the owner. Prints the in-site relative path. |
owner-import-db |
borg-apiscp-restore owner-import-db SITE ENGINE DB |
Site-owner, destructive: equivalent to import-db for the site's own database. |
(no args / -h/--help/help) |
Print the header usage comment and exit 0. | |
| (anything else) | Print unknown command: <cmd> to stderr and exit 2. |
Every "owner-*" subcommand takes SITE as an argument, but in every real
invocation path that argument is supplied by the trusted Layer 2 module
(borg.php), which derives it from $this->getAuthContext()->site (the
authenticated session), never from a value a site user could control. The
Layer 1 binaries additionally validate SITE matches site[0-9]*.
Current limitation, by design in this release: restore always uses the
newest archive of a matching prefix. There is no way, from the CLI or the
GUI, to restore an older archive by id/time, and owner-files/site support
subpath filtering only within the shadow tree, not a general browse picker.
import-db/owner-import-db take no dump-file argument at all: they always
restore-and-import the newest SITE-db archive's dump for the named
database.
6. Panel/API reference (Layer 2)
Borg_Module_Surrogate (src/modules/borg.php, installed as
lib/modules/surrogates/borg.php) exposes every verb below as cpcmd borg:<verb> and to the two GUI apps (Page_Container::__call forwards
$this->borg_<verb>(...) calls to the module). Every verb runs one of the
three Layer 1 binaries as root via a scoped sudo -n, with a positional
printf-style format string so Util_Process escapes every argument (see
section 9).
Admin verbs (PRIVILEGE_ADMIN)
| Verb | Parameters | Description |
|---|---|---|
get_config |
Return the current config as an associative array, with any key containing PASSPHRASE masked to ********. |
|
set_option |
$key, $value |
Set one whitelisted config key (client-side newline check only; config-set is the authoritative validator). |
status |
Return borg-apiscp-repo status output. |
|
check_access |
Return borg-apiscp-repo check-access output (ok or an error message). |
|
key_backed_up |
true if key-status reports yes. |
|
backup_key |
$dest = null, $force = false |
Run backup-key, optionally to a custom destination, optionally forced. |
key_bundle |
Return the one-time recovery bundle text (secrets included by design). | |
run |
$site = '', $path = '' |
Trigger a backup: $path takes precedence over $site; with neither, runs everything. $site is validated against ^site\d+$. |
is_running |
true if the backup service is currently active. |
|
sites |
Return siteN => domain for every ApisCP site, sorted by key. |
|
site_snapshots |
$site |
Archives available for a site's files, newest first, as [{id,time}, ...]; the archive name is the id. Informational only: restore always uses the newest. |
restore_site |
$site, $target |
Restore a site's newest shadow+info archives into a staging directory. There is no point-in-time selection or subpath picker exposed here (unlike the Layer 1 site subcommand's --subpath); the whole site tree is always restored. |
init_repo |
Create (initialise) the configured repository. Idempotent; requires a passphrase already set. | |
gen_passphrase |
Generate and store a strong repository passphrase; returns it once. | |
set_backup_selection |
$sites, $system, $paths = [] |
Set BACKUP_SITES (collapsing to all if the selection covers every known site, none if empty, otherwise a CSV), BACKUP_SYSTEM, and BACKUP_PATHS (each path re-validated with validate_path). |
set_schedule |
$when |
Validate $when as HH:MM or a safe OnCalendar-like expression, store it in BACKUP_SCHEDULE, then call set-schedule. |
maintenance |
Run borg compact, detached. |
|
verify |
$data = false |
Run borg check, detached; $data adds --verify-data (reads file contents back). |
notify_test |
Send a test notification email. | |
prune |
Apply retention (borg prune) across every archive prefix right now, per the configured RETENTION_KEEP_* settings. Unlike kopia's global policy, there is no separate "apply policy" step: retention lives in config, and prune (also run automatically after each backup) is what enforces it. |
|
repo_info |
Return ['connected'=>bool, 'location'=>string], computed from get_config()['BORG_REPO'] and a live check-access call; there is no repository "type" in the Borg model. |
|
validate_path |
$path |
true if the path is a plausible absolute path (non-empty, starts with /, no CR/LF). Unlike kopia's validate-path, there is no corresponding root-level CLI check: this is a best-effort structural check performed entirely in PHP, not authoritative (a path that does not actually exist when the engine runs is simply skipped with a warning in the log). |
sites_with_snapshots |
Site ids that have at least one siteN-shadow archive (for greying out empty sites in the restore picker). |
|
site_databases |
$site |
[{engine, db}, ...] currently known for a site (live, from ApisCP, not from an archive). |
database_backups |
$site |
[{id, time}, ...] (newest first) of dated siteN-db archives for a site. Informational: restore/import always use the newest. |
restore_database |
$site, $engine, $db, $target |
Non-destructive restore of one database dump to a staging path. |
restore_account |
$site, $target |
Restore a whole account (files + all databases) to a staging directory. |
import_database |
$site, $engine, $db |
Destructive import of the newest archived dump into the live database. No dump-file parameter: import-db always restores and imports the newest siteN-db archive itself. |
restore_system |
$target |
Restore the shared system archive to a staging directory. No subpath parameter is exposed (unlike kopia's restore_system). |
Site-owner verbs (PRIVILEGE_SITE)
Every verb below resolves the caller's own site from the ApisCP auth context
via a private authSite() helper ($this->getAuthContext()->site, validated
against ^site\d+$) and never accepts a site identifier as a parameter. Where
a database is named, it is additionally checked to belong to that resolved
site (ownsDatabase()) before use.
| Verb | Parameters | Description |
|---|---|---|
my_site |
['site'=>siteN|'', 'domain'=>string] for the caller. |
|
my_snapshots |
[{id,time}, ...] archives available for the caller's own site. Informational only; restore always uses the newest. |
|
restore_my_files |
Restore the caller's own files (newest archive) into their own site filesystem (chowned to them), returning the in-site relative path. No point-in-time or subpath selection in this release. | |
my_databases |
[{engine,db}, ...] for the caller's own site. |
|
my_database_backups |
[{id,time}, ...] dated database archives for the caller's own site. |
|
restore_my_database |
$engine, $db |
Non-destructive restore of one of the caller's own databases (newest archive) into their own filesystem; rejected if the database is not one the site currently owns. |
import_my_database |
$engine, $db, $confirm = false |
Destructive: import the newest archived dump of one of the caller's own databases back into their live database. Requires $confirm === true. |
restore_my_account |
Restore the caller's whole account (files + all databases, newest archives) into their own filesystem, chowned to them. | |
run_my_backup |
Trigger a detached backup run scoped to only the caller's own site. |
7. Automations
Scheduled backup (systemd timer)
systemd/borg-apiscp-backup.timer runs borg-apiscp-backup.service
(Type=oneshot, Nice=10, IOSchedulingClass=idle, TimeoutStartSec=0 so a
long backup is never killed as if hung).
| Setting | Value | Purpose |
|---|---|---|
OnCalendar |
*-*-* 03:30:00 |
Nightly at 03:30, before jitter |
RandomizedDelaySec |
1800 |
Up to 30 minutes of jitter so a fleet of servers does not hit shared storage simultaneously |
Persistent |
true |
Catch up with a missed run if the machine was off at the scheduled time |
BACKUP_SCHEDULE overrides OnCalendar without editing the shipped unit:
borg-apiscp-repo set-schedule [SPEC] (or cpcmd borg:set_schedule, or the
GUI time picker) writes
/etc/systemd/system/borg-apiscp-backup.timer.d/override.conf containing an
empty OnCalendar= (clearing the base unit's value) followed by
OnCalendar=<computed value>, then runs systemctl daemon-reload and
systemctl restart borg-apiscp-backup.timer. SPEC may be HH:MM (becomes
*-*-* HH:MM:00) or a full OnCalendar expression.
Retention (borg prune)
There is no separate "apply policy" step as with kopia's global policy.
Retention lives directly in the RETENTION_KEEP_* config keys, and borg prune is what enforces it, in two places:
- Automatically, after every backup run, per archive-name prefix (each
site's
shadow,info, anddbarchives pruned separately, plus_system-*and_custom-<slug>-*), right insideborg-apiscp-backup, so retention never falls behind the schedule. - On demand, via
borg-apiscp-repo prune(cpcmd borg:prune, or the GUI's "Prune now" button), which sweeps every site'sshadow/info/dbprefixes plus_system-*/_custom-*in one pass, using whicheverRETENTION_KEEP_*values are currently set. If none are set, it logs and does nothing.
Maintenance and verify (detached)
borg-apiscp-repo maintenance runs borg compact (reclaims space freed by
pruned archives); borg-apiscp-repo verify [--data] runs borg check
(repository and archive consistency; --data also reads back every file's
content, slower but thorough). Both are launched via systemd-run --collect
when available (falling back to a backgrounded shell job) specifically so a
web/API caller (the GUI, cpcmd) returns immediately rather than blocking on
a potentially long-running repository operation.
Email notifications
Configured via NOTIFY_EMAIL / NOTIFY_ON / NOTIFY_FROM. Sent via the
local sendmail (tried at /usr/sbin/sendmail, /usr/lib/sendmail, or
sendmail on PATH); if none is found, mail is skipped as best-effort. After
every borg-apiscp-backup run (success, failure, or an interrupted/crashed
run, all handled by an EXIT/INT/TERM trap), notify() sends a plain
text summary (host, result, finish time, archives ok/failed, database export
result, duration, repository location) if NOTIFY_ON calls for it: never
(never), always (always), or only when the run did not fully succeed
(failure, the default; an interrupted run that never reached its normal end
also counts as a failure). borg-apiscp-repo notify-test (cpcmd borg:notify_test, GUI "Send test email") sends a one-off confirmation
message independent of an actual backup run.
Account-lifecycle hooks
ApisCP runs config/custom/hooks/<event>.sh with the site id (siteN) as
$1 after the named account event. All three hooks below always exit 0
(hooks cannot interrupt or fail the underlying ApisCP operation) and run
strictly after the event has already completed, not before or during it.
| Hook | Source | Timing | Action |
|---|---|---|---|
addDomain.sh |
src/hooks/addDomain.sh |
After a domain/account is created | Kicks off a detached backup scoped to just the new site (borg-apiscp-repo run --site <site>), so a brand-new account is protected immediately rather than waiting for the next scheduled run. |
suspendDomain.sh |
src/hooks/suspendDomain.sh |
After a domain/account is suspended | Takes one final detached backup of the site (same run --site call) to capture its state at the moment of suspension, since a suspended account's files still exist on disk. |
deleteDomain.sh |
src/hooks/deleteDomain.sh |
After a domain/account is deleted | Does not purge the site's archives. Only appends a timestamp\tsite line to an audit log ($APISCP_BORG_DELETED_LOG, default /var/lib/apiscp-borg/deleted-accounts.log) and logs to syslog. Existing archives are retained under the repository's normal retention policy; any purge is a deliberate, manual (or retention-window) decision, never automatic. |
All three are installed only if the destination hook file does not already
exist, or exists but does not already contain the string apiscp-borg (an
operator's own custom hook is never overwritten).
8. Both GUIs
Appliance-admin panel: "Borg Backups" (src/apps/borg/)
Registered at /apps/borg via config/custom/templates/admin.php, under the
admin menu's "System" category (internal id services). Every action maps
to one PRIVILEGE_ADMIN verb from section 6. Sections, top to bottom:
- Repository: a compact connected/not-connected status line with a "Show
details" toggle (raw
borg info). When a repository is already connected, the connection fields are prepopulated and disabled behind a "Reconfigure repository" checkbox that must be ticked to unlock them. There is only one repository shape (a path or anssh://URL): fields are the repository location, passphrase, an optional "generate a strong passphrase" checkbox, an encryption-mode selector (repokey-blake2/keyfile-blake2), and SSH options (BORG_RSH); there is no backend "type" selector, no server mode, and no SFTP key upload, unlike the kopia panel. "Save settings" persists; "Check access" confirms the repository is reachable with the current passphrase. Creating a new repository requires ticking an explicit acknowledgement ("I understand that if the repository passphrase/key is lost, the backups are permanently irrecoverable") before "Initialise repository" is enabled; immediately after creation, a one-time red-bordered panel displays the full recovery bundle in a read-only textarea plus a "Download recovery key" button (client-sideBlobdownload, no extra request), and is not shown again on reload. - What to back up: a two-box Available/Backing-up multi-select over
sites, "System information", and any configured custom paths, with arrow
buttons to move entries between boxes and a "Save selection" button, plus
a checkbox for whether site databases are included. A separate text field
plus "Add custom path" validates and appends an arbitrary absolute path
(via
validate_path) to the backing-up box. - Retention and excludes: a "Within" duration field (e.g.
7d,4w,6m,1y) plus four archive-count fields (Daily/Weekly/Monthly/Annual; blank = no limit), and a textarea of exclude globs, one per line (stored comma-separated on disk); a note that/.borg-restoreis always excluded. "Save retention and excludes" persists the config; "Prune now" runsborg pruneimmediately using whatever is currently saved. - Run: a status badge (idle / a backup is currently running, which disables the button) and a mode selector (everything / one site / a custom path) with "Run now" (always detached).
- Schedule and maintenance: an HTML time input bound to
BACKUP_SCHEDULEwith "Save schedule" (a systemdOnCalendarexpression can still be set from the CLI), plus "Run maintenance now" (borg compact) and a "verify file contents" checkbox with "Verify now" (borg check, both detached). - Notifications: recipient email, an on-failure/always/never selector, a sender override, "Save", and "Send test email".
- Repository key backup: shows whether the one-time key/passphrase
backup sentinel exists, a destination field (defaults to
KEY_BACKUP_DIR), "Back up key now", and a "force (redo)" checkbox. - Restore: a single workflow with a "What would you like to restore?"
type selector (a site's files / an entire account / a single database /
system information) that swaps in the matching sub-panel via JavaScript,
rather than four separate pages. A note states plainly that Borg does not
yet support point-in-time archive selection or a subpath picker in this
release: every restore uses the newest matching archive.
- Site files: pick a site (sites with no archives are disabled in the dropdown), "Show backups" lists archive dates for information (newest first, up to 6 shown); a target field and "Restore newest archive".
- Entire account: pick a site (same archive-availability graying), a
target directory, "Restore entire account" (files plus all databases
under a
databases/subdirectory). - System information: a target directory and "Restore system" (non-destructive).
- Databases: pick a site, "List databases" shows each database with its
engine, the dated backup history (up to 6 most recent shown, for
information), and per-row "Restore dump to staging" (safe) versus
"Import newest backup into live database" (destructive, requires a
ticked confirmation checkbox and a JavaScript
confirm()dialog).
A full-page "running" overlay (spinner + the clicked button's label) appears on every form submit and blocks further clicks until the page reloads, since Borg operations on a large repository can take a while; the page also restores scroll position across the postback so a save action does not jump the operator back to the top.
Site-owner panel: "My Borg Backups" (src/apps/myborgbackups/)
Registered at /apps/myborgbackups via config/custom/templates/site.php,
under the site panel's "Account" category. Every action maps to one
PRIVILEGE_SITE verb from section 6, and the page never sends a site
identifier of its own. If the caller's site cannot be resolved from the auth
context, the whole page shows only a warning to contact the administrator.
Sections:
- Restore files: "Show my backups" lists the caller's own archive dates (informational, up to 6 shown); "Restore newest backup into my files (with permissions)" restores the newest archive into the owner's own site filesystem. A note states there is no point-in-time selection or file picker in this release.
- Restore my entire account: one button, restores all files plus all
databases (newest archives) into
/.borg-restore/account-<date>/(databases under adatabases/subdirectory). - Databases: "List my databases" shows engine/name plus dated backup
history (informational); per-row "Restore dump to my files" (safe) and
"Import newest backup into live database" (destructive, ticked
confirmation plus a JS
confirm()). - Back up now: triggers an on-demand backup of just the caller's own site, in addition to the scheduled runs.
Every restore result banner states explicitly that the output landed under
/.borg-restore/ in the owner's own file space and can be retrieved with the
File Manager or SFTP. The same "running" overlay and scroll-position
preservation as the admin app are used here too.
Common restore landing convention: every site-owner restore (files,
database dump, or whole account) is written inside that site's own fst
tree under a timestamped subdirectory of /.borg-restore/, and the entire
/.borg-restore directory is then recursively chowned to the site's admin
user (akp_owner_chown, preferring chown --reference against the owner's
home directory, falling back to the numeric uid:gid parsed from the site's
own /etc/passwd) so the owner can browse, download, and delete it through
their own File Manager or SFTP account.
9. Security model
- The Layer 2 module runs inside the ApisCP frontend as the unprivileged
apnscpuser, which cannot read the root-owned Borg repository config or list/home/virtual. Every verb therefore shells out to one of the three Layer 1 binaries viaUtil_Process::exec('sudo -n <bin> %s %s ...', ...)(runTool()/runToolRaw()inborg.php), where the binary path is a fixed class constant and every argument is passed throughUtil_Process's own printf-style escaping, never string-concatenated into a shell command. install-layer2.shinstalls a scoped/etc/sudoers.d/apiscp-borggranting exactlyapnscp ALL=(root) NOPASSWD: <the three binary paths>(validated withvisudo -cfbefore being installed) - nothing else is permitted to run as root. The three binaries themselves therefore are the privilege boundary: they validate every input (site id pattern, database engine must bemysql/pgsql, database name character set, target must be absolute, subpaths cannot contain..or a leading/) before acting./etc/apiscp-borg/config(holds the repository passphrase) is kept root-owned (0600), never read or written directly by the panel user; all reads go throughconfig-getand all writes throughconfig-set, both of which run as root via the same sudoers grant.config-set(and the module'sset_option) enforce a fixed whitelist of settable keys (CONFIG_ALLOWEDinbin/borg-apiscp-repo, mirrored asBorg_Module_Surrogate::ALLOWED_KEYS) and reject any value containing a CR or LF, so a caller cannot smuggle extra config lines or inject shell metacharacters into a file that is later.-sourced by the engine; the stored value is additionally single-quote-escaped.- Site-owner (
PRIVILEGE_SITE) verbs never accept a site identifier as a parameter. Every one of them calls a privateauthSite()helper that reads$this->getAuthContext()->site(set by ApisCP's own auth framework to the authenticated session's site) and validates it against^site\d+$; if it cannot be resolved, the verb returns an error or an empty result rather than falling back to any caller-supplied value. Where a database name is also given (e.g.restore_my_database,import_my_database), it is cross-checked against that resolved site's current database list (ownsDatabase()) before use, so a site owner cannot reach another site's data even by guessing a database name. The corresponding Layer 1 subcommands (owner-files,owner-restore-db,owner-import-db,owner-account) repeat the site-pattern and path-traversal checks independently, so the guarantee does not rest on the module alone. - Per-site database isolation: ApisCP's own
<engine>:export/<engine>:importverbs resolve their file argument inside the target site's own filesystem namespace (a chroot-like view viafile_make_path), not the host filesystem, and a host-level dump/restore tool cannot see per-site databases at all. The engine and the CLI tools therefore always go through$APNSCP_CMD -d <domain> <engine>:{list_databases,export,import}in the site's own domain context: a dump is written to a site-relative path (e.g./tmp/.apiscp-borg-db/mysql/<db>.sql) which the engine then reads back from its real host-side location under<site>/fst/...for archiving, and an import stages the dump under the site's ownfstbefore calling the site-context import. This is what makes per-site database backup and restore correct in a multi-tenant ApisCP host where database names can collide or be invisible outside their own site. - A Borg repository is locked to one operation at a time (Borg's own
repository lock, in addition to the engine's local
$LOCK_FILE), so concurrent site-owner actions against the same repository queue rather than run in parallel; there is no risk of two writers corrupting the repository.
10. Restore and recovery
A Borg repository is encrypted; if the passphrase (and, for keyfile-mode
repositories, the exported key) are both lost, the data is permanently
unrecoverable no matter how many copies of the encrypted archives exist.
borg-apiscp-repo backup-key (see section 5) exports everything needed to
recover onto a fresh machine, and refuses to silently redo that export more
than once (use --force to intentionally redo it after a credential
rotation).
To recover the repository itself on a brand-new machine, using either the
persistent key backup directory or the one-time key-bundle output:
# 1. install borgbackup (your package manager, e.g. dnf/apt install borgbackup)
# 2. obtain the repository passphrase (and, for keyfile-mode repositories,
# the exported key), from either:
# - the key backup directory (repository.passphrase + repository.key), or
# - the one-time key-bundle text saved at repository-creation time
# 3. export the passphrase and the repository location
export BORG_PASSPHRASE="$(cat repository.passphrase)"
export BORG_REPO="<the repository location, e.g. /mnt/backups/apiscp-borg or ssh://...>"
# 4. only for keyfile-mode repositories (repokey-blake2 keeps the key IN the
# repository, so this step is unnecessary for the default encryption mode):
borg key import :: repository.key
# 5. confirm the archives are visible
borg list
# 6. extract whatever is needed, e.g. the newest siteN-shadow archive
borg extract ::<archive-name>
From there, install apiscp-borg itself (install.sh, then point
BORG_REPO/BORG_PASSPHRASE at the reconnected repository) to regain the
per-site restore tooling (borg-apiscp-restore), or continue with plain
borg list / borg extract if only a bare borg binary is available: no
manual ACL/xattr replay step is needed, because Borg already restored that
metadata as part of borg extract. The repository and its archives are
never deleted by uninstall.sh (with or without --purge), so reinstalling
this project at any point picks up exactly where the repository left off.