fix(engine)+docs: env overrides config for targeted runs; add full REFERENCE.md

- 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.
This commit is contained in:
Laurence Horrocks-Barlow 2026-07-24 23:38:08 +01:00
parent 02403b72c0
commit b9e8694c61
3 changed files with 785 additions and 0 deletions

View file

@ -48,6 +48,13 @@ restore tools, Layer 2 panel integration (module + two GUIs + hooks), install /
uninstall, and full reference documentation are being built out to track the uninstall, and full reference documentation are being built out to track the
apiscp-kopia feature set. Not yet ready for production use. apiscp-kopia feature set. Not yet ready for production use.
## Documentation
- `docs/REFERENCE.md`: exhaustive reference for every command, verb,
configuration key, installer, and automation shipped in this repository.
- `docs/DESIGN.md`: design rationale, and how Borg's native ACL/xattr
handling changes the approach taken relative to apiscp-kopia.
## License ## License
MIT. See `LICENSE`. MIT. See `LICENSE`.

View file

@ -32,8 +32,21 @@ done
# --- configuration ------------------------------------------------------------- # --- configuration -------------------------------------------------------------
CONFIG_FILE="${APISCP_BORG_CONFIG:-/etc/apiscp-borg/config}" CONFIG_FILE="${APISCP_BORG_CONFIG:-/etc/apiscp-borg/config}"
# Capture run-scoped overrides passed via the environment (e.g. by
# `borg-apiscp-repo run --site X`, which sets these via systemd-run) BEFORE
# sourcing the config, then re-apply them AFTER, so a targeted run is not
# clobbered by the persistent config defaults. `${VAR-x}` (single dash)
# distinguishes unset (use config) from set-but-empty (an explicit override).
_env_sites="${BACKUP_SITES-__unset__}"
_env_system="${BACKUP_SYSTEM-__unset__}"
_env_paths="${BACKUP_PATHS-__unset__}"
_env_db="${BACKUP_DATABASES-__unset__}"
# shellcheck source=/dev/null # shellcheck source=/dev/null
[ -r "$CONFIG_FILE" ] && . "$CONFIG_FILE" [ -r "$CONFIG_FILE" ] && . "$CONFIG_FILE"
[ "$_env_sites" != "__unset__" ] && BACKUP_SITES="$_env_sites"
[ "$_env_system" != "__unset__" ] && BACKUP_SYSTEM="$_env_system"
[ "$_env_paths" != "__unset__" ] && BACKUP_PATHS="$_env_paths"
[ "$_env_db" != "__unset__" ] && BACKUP_DATABASES="$_env_db"
: "${APNSCP_ROOT:=/usr/local/apnscp}" : "${APNSCP_ROOT:=/usr/local/apnscp}"
: "${APNSCP_CMD:=/usr/local/apnscp/bin/cmd}" : "${APNSCP_CMD:=/usr/local/apnscp/bin/cmd}"

765
docs/REFERENCE.md Normal file
View file

@ -0,0 +1,765 @@
# 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](https://www.borgbackup.org/). 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.
```sh
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.
```sh
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 (`borg` and
`myborgbackups`), the account hooks (only the ones containing the string
`apiscp-borg`), the scoped sudoers file, and the `apiscp-borg` lines
stripped out of `admin.php`/`site.php` (the template file itself is deleted
only if nothing but `<?php` and 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.d` schedule drop-in are
removed, `systemctl daemon-reload` runs, 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:
1. Acquire `$LOCK_FILE` via `flock` (non-blocking; a second concurrent run
aborts immediately) if `flock` is available.
2. If `RUN_DB_EXPORT=1`, run `$DB_EXPORT_CMD` (ApisCP's `backup_dbs.php`) so
per-site `.sql` dumps are current before they are archived.
3. For every `$VIRTBASE/site<N>` directory (skipping any not selected by
`BACKUP_SITES`):
- archive `shadow` (if present) as `site<N>-shadow-{now}`;
- archive `info` (if present) as `site<N>-info-{now}`;
- if `BACKUP_DATABASES=1`: enumerate the site's `mysql` and `pgsql`
databases 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's
`fst` namespace by ApisCP), archive that staging tree from its real host
location (`<site>/fst/tmp/.apiscp-borg-db`) as `site<N>-db-{now}`, then
delete the staging tree from the site;
- apply per-archive-prefix retention (`borg prune`) for
`site<N>-shadow-*`, `site<N>-info-*`, and `site<N>-db-*` right away, so
one site's daily churn is pruned every run rather than waiting for a
separate step.
4. If `BACKUP_SYSTEM=1`: expand `SYSTEM_PATHS` and matching `SYSTEM_GLOBS`
entries to those that actually exist, archive them together as
`_system-{now}`, then prune `_system-*`.
5. If `BACKUP_PATHS` is non-empty: for each existing custom path, archive it
as `_custom-<slug>-{now}` (slug via `akp_slug`), then prune
`_custom-<slug>-*`.
6. Write Prometheus textfile metrics (always, even on failure or interrupt,
via an `EXIT`/`INT`/`TERM` trap) and send an email notification per
`NOTIFY_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:
1. **Automatically, after every backup run**, per archive-name prefix (each
site's `shadow`, `info`, and `db` archives pruned separately, plus
`_system-*` and `_custom-<slug>-*`), right inside
`borg-apiscp-backup`, so retention never falls behind the schedule.
2. **On demand**, via `borg-apiscp-repo prune` (`cpcmd borg:prune`, or the
GUI's "Prune now" button), which sweeps every site's `shadow`/`info`/`db`
prefixes plus `_system-*`/`_custom-*` in one pass, using whichever
`RETENTION_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 an `ssh://` 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-side `Blob`
download, 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-restore` is always excluded.
"Save retention and excludes" persists the config; "Prune now" runs `borg
prune` immediately 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_SCHEDULE`
with "Save schedule" (a systemd `OnCalendar` expression 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 a `databases/` 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
`apnscp` user, 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 via `Util_Process::exec('sudo -n <bin> %s %s ...', ...)`
(`runTool()`/`runToolRaw()` in `borg.php`), where the binary path is a
fixed class constant and every argument is passed through
`Util_Process`'s own printf-style escaping, never string-concatenated into
a shell command.
- `install-layer2.sh` installs a scoped `/etc/sudoers.d/apiscp-borg` granting
exactly `apnscp ALL=(root) NOPASSWD: <the three binary paths>` (validated
with `visudo -cf` before 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 be `mysql`/`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 through `config-get` and all writes through `config-set`, both of
which run as root via the same sudoers grant.
- `config-set` (and the module's `set_option`) enforce a fixed whitelist of
settable keys (`CONFIG_ALLOWED` in `bin/borg-apiscp-repo`, mirrored as
`Borg_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 private `authSite()` 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>:import`
verbs resolve their file argument **inside the target site's own filesystem
namespace** (a chroot-like view via `file_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 own `fst` before
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:
```sh
# 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.