feat(borg): Layer 2 panel integration (module + two GUIs + hooks + installers)

Native ApisCP integration, adapted from apiscp-kopia's Layer 2 against the real
borg Layer 1 subcommands:
- Borg_Module_Surrogate: admin + PRIVILEGE_SITE verbs, sudo boundary, config
  whitelist (now enforced), site-owner verbs derive the site from the auth
  context only, ownsDatabase gate, confirm-guarded destructive import.
- Admin GUI (apps/borg, "Borg Backups"): repository (BORG_REPO/passphrase/
  encryption/RSH, check-access, init, one-time recovery-key panel + download +
  irrecoverable tick), backup selection, retention + excludes + prune-now,
  schedule, maintenance (compact) + verify (check), notifications, tabbed layout,
  running overlay, double-submit guard, consolidated Restore workflow.
- Site-owner GUI (apps/myborgbackups, "My Borg Backups"): restore files / whole
  account / databases, run own backup, scoped to the caller's own site.
- Account hooks, install-layer2.sh, uninstall.sh (safe default, --purge).

Borg differences vs kopia are handled: no backend types/sftp/server, no browse/
subpath or point-in-time (restores newest archive; archive dates shown for info),
retention via config + `borg prune` (no global policy), import-db takes no
dumpfile. All PHP lints clean; controller verb calls all resolve to the module.
This commit is contained in:
Laurence Horrocks-Barlow 2026-07-24 23:18:35 +01:00
parent 3ce5bf1a47
commit 02403b72c0
14 changed files with 2681 additions and 0 deletions

148
install-layer2.sh Normal file
View file

@ -0,0 +1,148 @@
#!/bin/sh
#
# install-layer2.sh: install the native ApisCP integration (module + GUI apps +
# menu links + hooks) into their upgrade-safe locations.
#
# Every target is a git-ignored path ApisCP preserves across upgrades:
# - module -> $CP_ROOT/lib/modules/surrogates/borg.php (.gitignore: surrogates/*)
# - admin app -> $CP_ROOT/config/custom/apps/borg/ (config/custom is all-ignored)
# - site app -> $CP_ROOT/config/custom/apps/myborgbackups/ (site-owner edition)
# - admin menu-> $CP_ROOT/config/custom/templates/admin.php
# - site menu -> $CP_ROOT/config/custom/templates/site.php
#
# Run as root on an ApisCP server AFTER install.sh (which installs the Layer 1
# binaries the module calls). Idempotent.
set -eu
[ "$(id -u)" = 0 ] || { echo "run as root" >&2; exit 1; }
SRC=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
# Locate ApisCP.
CP_ROOT="${CP_ROOT:-/usr/local/apnscp}"
[ -d "$CP_ROOT/lib/modules" ] || { echo "ApisCP not found at $CP_ROOT (set CP_ROOT)" >&2; exit 1; }
OWNER=apnscp
echo "CP_ROOT = $CP_ROOT"
# 1. Backend module.
echo "installing module -> lib/modules/surrogates/borg.php"
install -d -o "$OWNER" -g "$OWNER" "$CP_ROOT/lib/modules/surrogates"
install -m 0644 -o "$OWNER" -g "$OWNER" "$SRC/src/modules/borg.php" "$CP_ROOT/lib/modules/surrogates/borg.php"
# 2. GUI app (whole tree).
echo "installing app -> config/custom/apps/borg/"
install -d -o "$OWNER" -g "$OWNER" "$CP_ROOT/config/custom/apps/borg/views"
install -m 0644 -o "$OWNER" -g "$OWNER" "$SRC/src/apps/borg/borg.php" "$CP_ROOT/config/custom/apps/borg/borg.php"
install -m 0644 -o "$OWNER" -g "$OWNER" "$SRC/src/apps/borg/application.yml" "$CP_ROOT/config/custom/apps/borg/application.yml"
install -m 0644 -o "$OWNER" -g "$OWNER" "$SRC/src/apps/borg/views/index.blade.php" "$CP_ROOT/config/custom/apps/borg/views/index.blade.php"
# 2b. Site-owner GUI app (whole tree). Same module, scoped PRIVILEGE_SITE verbs.
echo "installing site-owner app -> config/custom/apps/myborgbackups/"
install -d -o "$OWNER" -g "$OWNER" "$CP_ROOT/config/custom/apps/myborgbackups/views"
install -m 0644 -o "$OWNER" -g "$OWNER" "$SRC/src/apps/myborgbackups/myborgbackups.php" "$CP_ROOT/config/custom/apps/myborgbackups/myborgbackups.php"
install -m 0644 -o "$OWNER" -g "$OWNER" "$SRC/src/apps/myborgbackups/application.yml" "$CP_ROOT/config/custom/apps/myborgbackups/application.yml"
install -m 0644 -o "$OWNER" -g "$OWNER" "$SRC/src/apps/myborgbackups/views/index.blade.php" "$CP_ROOT/config/custom/apps/myborgbackups/views/index.blade.php"
# 3. Menu link. Append to an existing custom admin.php rather than clobber it.
echo "installing menu link -> config/custom/templates/admin.php"
install -d -o "$OWNER" -g "$OWNER" "$CP_ROOT/config/custom/templates"
DEST="$CP_ROOT/config/custom/templates/admin.php"
if [ -f "$DEST" ]; then
if grep -q "/apps/borg" "$DEST"; then
echo " link already present; leaving $DEST untouched"
else
echo " appending create_link() to existing $DEST"
if grep -q '?>' "$DEST"; then
echo " WARNING: $DEST contains a closing ?>; add the create_link() call by hand" >&2
else
cat >> "$DEST" <<'PHP'
// apiscp-borg
$templateClass->create_link('Borg Backups', '/apps/borg', true, null, null);
PHP
fi
fi
else
install -m 0644 -o "$OWNER" -g "$OWNER" "$SRC/src/templates/admin.php" "$DEST"
fi
# 3b. Site-owner menu link. Same append-not-clobber logic for the site template.
echo "installing site menu link -> config/custom/templates/site.php"
SITE_DEST="$CP_ROOT/config/custom/templates/site.php"
if [ -f "$SITE_DEST" ]; then
if grep -q "/apps/myborgbackups" "$SITE_DEST"; then
echo " link already present; leaving $SITE_DEST untouched"
elif grep -q '?>' "$SITE_DEST"; then
echo " WARNING: $SITE_DEST contains a closing ?>; add the create_link() call by hand" >&2
else
echo " appending create_link() to existing $SITE_DEST"
cat >> "$SITE_DEST" <<'PHP'
// apiscp-borg (site-owner)
$templateClass->create_link('My Borg Backups', '/apps/myborgbackups', true, null, 'account');
PHP
fi
else
install -m 0644 -o "$OWNER" -g "$OWNER" "$SRC/src/templates/site.php" "$SITE_DEST"
fi
# 3c. Account-lifecycle hooks. ApisCP runs config/custom/hooks/<event>.sh after
# create/suspend/delete with the site id as the first argument. Append-safe:
# only install ours if not already present (do not clobber operator hooks).
echo "installing account hooks -> config/custom/hooks/"
install -d -o "$OWNER" -g "$OWNER" "$CP_ROOT/config/custom/hooks"
for _hook in addDomain suspendDomain deleteDomain; do
_dest="$CP_ROOT/config/custom/hooks/$_hook.sh"
if [ -e "$_dest" ] && ! grep -q 'apiscp-borg' "$_dest" 2>/dev/null; then
echo " $_hook.sh exists and is not ours; leaving it (add our call by hand if wanted)"
else
install -m 0755 -o "$OWNER" -g "$OWNER" "$SRC/src/hooks/$_hook.sh" "$_dest"
echo " installed $_hook.sh"
fi
done
# 4. Sudoers. The panel runs the module as the unprivileged ApisCP user, but the
# Borg repository and /home/virtual are root-only. Let that user run ONLY the
# three plugin binaries as root, nothing else. The binaries validate their own
# inputs and are the privilege boundary.
PANEL_USER="${PANEL_USER:-apnscp}"
echo "installing sudoers -> /etc/sudoers.d/apiscp-borg (user: $PANEL_USER)"
_sudo_tmp=$(mktemp)
cat > "$_sudo_tmp" <<EOF
# Installed by apiscp-borg install-layer2.sh. Do not edit by hand.
Defaults:$PANEL_USER !requiretty
Cmnd_Alias APISCP_BORG_CMDS = /usr/local/bin/borg-apiscp-backup, /usr/local/bin/borg-apiscp-restore, /usr/local/bin/borg-apiscp-repo
$PANEL_USER ALL=(root) NOPASSWD: APISCP_BORG_CMDS
EOF
if visudo -cf "$_sudo_tmp" >/dev/null 2>&1; then
install -m 0440 -o root -g root "$_sudo_tmp" /etc/sudoers.d/apiscp-borg
echo " sudoers installed and validated"
else
echo " WARNING: sudoers validation failed; the GUI will not be able to run as root" >&2
visudo -cf "$_sudo_tmp" 2>&1 | sed 's/^/ /' >&2
fi
rm -f "$_sudo_tmp"
# Keep the engine config root-only; it holds the repository passphrase and is
# read/written by the root binaries, never directly by the panel user.
[ -f /etc/apiscp-borg/config ] && chown root:root /etc/apiscp-borg/config && chmod 0600 /etc/apiscp-borg/config
# Restart the panel so it picks up the new apps and menus (module is picked up
# on next CLI/API call without a restart).
echo "restarting ApisCP panel"
systemctl restart apnscp || echo " restart apnscp yourself to see the new menu entry" >&2
cat <<'EOF'
Layer 2 installed. Verify:
cpcmd borg:sites
cpcmd borg:status
Then load the appliance-admin panel; "Borg Backups" appears in the menu.
Site owners get a scoped "My Borg Backups" entry in the site panel (Account
section) backed by the PRIVILEGE_SITE verbs (borg:my_site, my_snapshots,
restore_my_files, my_databases, my_database_backups, restore_my_database,
import_my_database, restore_my_account, run_my_backup). The menu is
session-cached, so a site owner sees it after a fresh login.
EOF

View file

@ -0,0 +1,13 @@
vars:
title: Borg Backups
tagline: >-
Configure the Borg repository, back up its passphrase, run backups, and
restore sites, databases, and whole accounts.
help: >-
This panel drives the apiscp-borg engine. The repository is a Borg
repository, a local path or an ssh:// URL, protected by a passphrase. Back
up the passphrase (and, for keyfile mode, the key) once and store it off
this machine: without it the encrypted backups are permanently
irrecoverable. Archives are per site so a single site can be restored on
its own. All actions here are also available from the command line as
<code>cpcmd borg:&lt;verb&gt;</code>.

480
src/apps/borg/borg.php Normal file
View file

@ -0,0 +1,480 @@
<?php
declare(strict_types=1);
/**
* apiscp-borg GUI app controller.
*
* Installed (upgrade-safe) at config/custom/apps/borg/borg.php. Runs in the
* appliance-admin panel and drives the same backend verbs as the CLI: every
* action calls $this->borg_<verb>(), forwarded to Borg_Module_Surrogate.
*
* @license MIT
*/
namespace apps\borg;
class Page extends \Page_Container
{
/** @var string site chosen in the restore form (postback) */
protected $selectedSite = '';
/** @var array archives for the chosen site (informational only) */
protected $selectedSnaps = [];
/** @var string site chosen in the databases panel (postback) */
protected $dbSite = '';
/** @var array databases for the chosen site: [{engine,db}, ...] */
protected $dbList = [];
/** @var bool a repository was just created in this request (show key once) */
protected $justCreated = false;
/** @var string one-time recovery bundle to display/download after creation */
protected $createdBundle = '';
public function __construct()
{
parent::__construct();
}
/**
* Set the postback status banner. Page_Container::bind() takes its
* argument by reference, so a literal, ternary, or function result cannot
* be passed to it directly (fatal "could not be passed by reference" on
* PHP 8.1+). Route every status through a local variable here.
*
* @param mixed $status truthy for success, false for failure
* @return void
*/
private function status($status): void
{
$s = $status;
$this->bind($s);
}
/* ---- view helpers (read-only, called from the Blade template) ---- */
public function config(): array
{
$r = $this->borg_get_config();
return is_array($r) ? $r : [];
}
public function repoInfo(): array
{
$r = $this->borg_repo_info();
return is_array($r) ? $r : ['connected' => false, 'location' => ''];
}
public function repoConnected(): bool
{
return !empty($this->repoInfo()['connected']);
}
public function repoStatus(): string
{
$r = $this->borg_status();
return is_string($r) ? $r : '(unable to read repository status)';
}
public function sites(): array
{
$r = $this->borg_sites();
return is_array($r) ? $r : [];
}
public function sitesWithSnapshots(): array
{
$r = $this->borg_sites_with_snapshots();
return is_array($r) ? $r : [];
}
public function selectedSite(): string { return $this->selectedSite; }
public function selectedSnaps(): array { return $this->selectedSnaps; }
public function dbSite(): string { return $this->dbSite; }
public function dbList(): array { return $this->dbList; }
/**
* Which restore sub-panel should be shown first: derived from postback
* state so a "List databases" round-trip lands back on the right panel.
* Defaults to the site-files panel.
*
* @return string one of files|database
*/
public function restoreMode(): string
{
if ($this->dbSite !== '') {
return 'database';
}
return 'files';
}
/** Dated database archives for the currently selected databases site. */
public function dbBackups(): array
{
if ($this->dbSite === '') {
return [];
}
$r = $this->borg_database_backups($this->dbSite);
return is_array($r) ? $r : [];
}
/**
* Format an ISO-8601 archive time into a readable UTC string, e.g.
* "2026-07-24 14:19 UTC". Falls back to the raw value if unparseable.
*/
public function fmtTime(?string $iso): string
{
$iso = (string)$iso;
$ts = strtotime($iso);
return $ts ? gmdate('Y-m-d H:i', $ts) . ' UTC' : $iso;
}
public function keyBackedUp(): bool { return (bool)$this->borg_key_backed_up(); }
/** True only right after a repository was created (show the key once). */
public function justCreated(): bool { return $this->justCreated; }
/** The one-time recovery bundle text to display/download after creation. */
public function createdBundle(): string { return $this->createdBundle; }
public function isRunning(): bool { return (bool)$this->borg_is_running(); }
/** Is a site (siteN) included by the current BACKUP_SITES policy? */
public function siteIncluded(string $id, ?array $cfg = null): bool
{
$cfg = $cfg ?? $this->config();
$sel = strtolower(trim((string)($cfg['BACKUP_SITES'] ?? 'all')));
if ($sel === '' || $sel === 'all') {
return true;
}
if ($sel === 'none') {
return false;
}
return in_array($id, array_map('trim', explode(',', $sel)), true);
}
/** Is system information included in the backup selection? */
public function systemIncluded(?array $cfg = null): bool
{
$cfg = $cfg ?? $this->config();
return (string)($cfg['BACKUP_SYSTEM'] ?? '1') !== '0';
}
/** Are databases included in the backup selection? */
public function databasesIncluded(?array $cfg = null): bool
{
$cfg = $cfg ?? $this->config();
return (string)($cfg['BACKUP_DATABASES'] ?? '1') !== '0';
}
/** Configured custom backup paths (BACKUP_PATHS). */
public function customPaths(?array $cfg = null): array
{
$cfg = $cfg ?? $this->config();
$raw = trim((string)($cfg['BACKUP_PATHS'] ?? ''));
if ($raw === '') {
return [];
}
return array_values(array_filter(array_map('trim', preg_split('/[,\s]+/', $raw)), 'strlen'));
}
/* ---- actions (postback) ---- */
public function on_postback($params)
{
if (isset($params['save_repo'])) {
$this->status($this->persistRepo($params));
} else if (isset($params['do_create'])) {
$this->createRepo($params);
} else if (isset($params['check_access'])) {
$ret = $this->borg_check_access();
$this->status($ret === false ? false : $ret);
} else if (isset($params['add_path'])) {
$this->addPath($params);
} else if (isset($params['save_selection'])) {
$this->saveSelection($params);
} else if (isset($params['save_policy'])) {
$this->savePolicy($params);
} else if (isset($params['run_prune'])) {
$ret = $this->borg_prune();
$this->status($ret === false ? false : true);
} else if (isset($params['save_schedule'])) {
$ret = $this->borg_set_schedule((string)($params['schedule'] ?? ''));
$this->status($ret === false ? false : true);
} else if (isset($params['run_maintenance'])) {
$ret = $this->borg_maintenance();
$this->status($ret === false ? false : true);
} else if (isset($params['run_verify'])) {
$ret = $this->borg_verify(!empty($params['verify_data']));
$this->status($ret === false ? false : true);
} else if (isset($params['save_notify']) || isset($params['test_notify'])) {
$this->saveNotify($params);
} else if (isset($params['backup_key'])) {
$ret = $this->borg_backup_key(
!empty($params['dest']) ? $params['dest'] : null,
!empty($params['force'])
);
$this->status($ret === false ? false : true);
} else if (isset($params['run'])) {
$this->doRun($params);
} else if (isset($params['list_snaps'])) {
$this->loadSnaps($params['site'] ?? '');
} else if (isset($params['restore'])) {
$this->doRestore($params);
} else if (isset($params['restore_account'])) {
if (empty($params['acct_site']) || empty($params['target'])) {
$this->status(error('site and target are required'));
} else {
$ret = $this->borg_restore_account((string)$params['acct_site'], (string)$params['target']);
$this->status($ret === false ? false : $ret);
}
} else if (isset($params['restore_system'])) {
$ret = $this->borg_restore_system(!empty($params['target']) ? (string)$params['target'] : '/var/tmp/restore-system');
$this->status($ret === false ? false : $ret);
} else if (isset($params['list_dbs'])) {
$this->loadDatabases($params['site'] ?? '');
} else if (isset($params['restore_db'])) {
$this->doRestoreDatabase($params);
} else if (isset($params['import_db'])) {
$this->doImportDatabase($params);
}
}
/** Populate the databases panel state for the chosen site. */
private function loadDatabases(string $site): void
{
$this->dbSite = $site;
if ($site !== '') {
$r = $this->borg_site_databases($site);
$this->dbList = is_array($r) ? $r : [];
}
}
private function doRestoreDatabase($params): void
{
if (empty($params['site']) || empty($params['engine']) || empty($params['db'])) {
$this->status(error('site, engine and database are required'));
} else {
$target = !empty($params['target'])
? (string)$params['target']
: '/var/tmp/restore-db-' . $params['db'];
$ret = $this->borg_restore_database((string)$params['site'], (string)$params['engine'], (string)$params['db'], $target);
$this->status($ret === false ? false : $ret);
}
// keep the table visible after the action
$this->loadDatabases((string)($params['site'] ?? ''));
}
private function doImportDatabase($params): void
{
if (empty($params['site']) || empty($params['engine']) || empty($params['db'])) {
$this->status(error('site, engine and database are required'));
} else if (empty($params['confirm_import'])) {
$this->status(error('tick confirm to import over the live database'));
} else {
// borg-apiscp-restore's import-db takes no dumpfile: it always
// restores and imports the newest siteN-db archive itself.
$ret = $this->borg_import_database((string)$params['site'], (string)$params['engine'], (string)$params['db']);
$this->status($ret === false ? false : $ret);
}
// keep the table visible after the action
$this->loadDatabases((string)($params['site'] ?? ''));
}
private function loadSnaps(string $site): void
{
$this->selectedSite = $site;
if ($site !== '') {
$r = $this->borg_site_snapshots($site);
$this->selectedSnaps = is_array($r) ? $r : [];
}
}
private function doRestore($params): void
{
if (empty($params['site']) || empty($params['target'])) {
$this->status(error('site and target are required'));
return;
}
$ret = $this->borg_restore_site((string)$params['site'], (string)$params['target']);
$this->status($ret === false ? false : true);
// keep the archive list visible after a restore
$this->loadSnaps((string)$params['site']);
}
private function doRun($params): void
{
$mode = $params['run_target'] ?? 'all';
if ($mode === 'path' && !empty($params['run_path'])) {
$ret = $this->borg_run('', (string)$params['run_path']);
} else if ($mode === 'site' && !empty($params['run_site'])) {
$ret = $this->borg_run((string)$params['run_site']);
} else {
$ret = $this->borg_run();
}
$this->status($ret === false ? false : $ret);
}
/** Add + validate a custom backup path, preserving the current selection. */
private function addPath($params): void
{
$p = trim((string)($params['new_path'] ?? ''));
if ($p === '') {
$this->status(error('enter a path to add'));
return;
}
if (!$this->borg_validate_path($p)) {
$this->status(error("path is not a valid absolute path: %s", $p));
return;
}
$cfg = $this->config();
$paths = $this->customPaths($cfg);
if (!in_array($p, $paths, true)) {
$paths[] = $p;
}
$ret = $this->borg_set_backup_selection($this->currentSites($cfg), $this->systemIncluded($cfg), $paths);
$this->status($ret === false ? false : true);
}
/**
* Save the notification recipient, policy and sender, and optionally
* send a test email (when the "Send test email" button was used).
*/
private function saveNotify($params): void
{
$email = trim((string)($params['NOTIFY_EMAIL'] ?? ''));
$on = (string)($params['NOTIFY_ON'] ?? 'failure');
$from = trim((string)($params['NOTIFY_FROM'] ?? ''));
if (!in_array($on, ['failure', 'always', 'never'], true)) {
$on = 'failure';
}
$ok = (false !== $this->borg_set_option('NOTIFY_EMAIL', $email));
$ok = (false !== $this->borg_set_option('NOTIFY_ON', $on)) && $ok;
$ok = (false !== $this->borg_set_option('NOTIFY_FROM', $from)) && $ok;
if (isset($params['test_notify'])) {
if ($email === '') {
$this->status(error('Enter an email address before sending a test.'));
return;
}
$ret = $this->borg_notify_test();
$this->status($ret === false ? false : $ret);
return;
}
$this->status($ok ? true : false);
}
/**
* Persist retention (RETENTION_KEEP_*) and exclude patterns. Unlike the
* kopia engine there is no global policy to push: retention lives in
* config and `borg prune` (run automatically after every backup, or on
* demand via the "Prune now" button) is what enforces it.
*/
private function savePolicy($params): void
{
$ok = true;
// RETENTION_KEEP_WITHIN is a duration like "7d" or "1m", not a count.
$within = trim((string)($params['RETENTION_KEEP_WITHIN'] ?? ''));
if ($within !== '' && !preg_match('/^[0-9]+[HdWMYhdwmy]$/', $within)) {
$this->status(error("invalid retention window `%s' (use a number followed by H, d, w, m or y)", $within));
return;
}
if (false === $this->borg_set_option('RETENTION_KEEP_WITHIN', $within)) {
$ok = false;
}
foreach (['RETENTION_KEEP_DAILY', 'RETENTION_KEEP_WEEKLY', 'RETENTION_KEEP_MONTHLY', 'RETENTION_KEEP_ANNUAL'] as $k) {
$v = trim((string)($params[$k] ?? ''));
// Keep only digits; a blank clears the limit for that bucket.
$v = ($v !== '' && ctype_digit($v)) ? $v : '';
if (false === $this->borg_set_option($k, $v)) {
$ok = false;
}
}
$raw = (string)($params['BACKUP_EXCLUDES'] ?? '');
$patterns = array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $raw)), 'strlen'));
if (false === $this->borg_set_option('BACKUP_EXCLUDES', implode(',', $patterns))) {
$ok = false;
}
$this->status($ok ? true : false);
}
/** Persist the two-box selection (sites + system + custom paths + databases). */
private function saveSelection($params): void
{
$backing = (isset($params['backing']) && is_array($params['backing'])) ? $params['backing'] : [];
$sites = [];
$system = false;
$paths = [];
foreach ($backing as $item) {
$item = (string)$item;
if ($item === '__system__') {
$system = true;
} else if (strpos($item, 'path:') === 0) {
$paths[] = substr($item, 5);
} else if (preg_match('/^site\d+$/', $item)) {
$sites[] = $item;
}
}
$ok = (false !== $this->borg_set_backup_selection($sites, $system, $paths));
$databases = !empty($params['backup_databases']);
$ok = (false !== $this->borg_set_option('BACKUP_DATABASES', $databases ? '1' : '0')) && $ok;
$this->status($ok ? true : false);
}
/** Current selected site ids resolved from config (for preserving on add). */
private function currentSites(array $cfg): array
{
$sel = strtolower(trim((string)($cfg['BACKUP_SITES'] ?? 'all')));
if ($sel === '' || $sel === 'all') {
return array_keys($this->sites());
}
if ($sel === 'none') {
return [];
}
return array_values(array_filter(array_map('trim', explode(',', $sel)), 'strlen'));
}
/** Persist the repository connection settings (path/URL, encryption, RSH, passphrase). */
private function persistRepo($params): bool
{
$keys = ['BORG_REPO', 'BORG_ENCRYPTION', 'BORG_RSH', 'BORG_PASSPHRASE'];
$ok = true;
foreach ($keys as $k) {
if (!array_key_exists($k, $params)) {
continue;
}
$v = (string)$params[$k];
if ($k === 'BORG_PASSPHRASE' && $v === '********') {
continue;
}
if (false === $this->borg_set_option($k, $v)) {
$ok = false;
}
}
return $ok;
}
/** Create-repository submit: acknowledge irrecoverability, optional
* generated passphrase, persist, initialise, then surface the recovery
* key once. */
private function createRepo($params): void
{
// Hard gate: the operator must acknowledge that losing the key means
// the backups are unrecoverable before a repository is created.
if (empty($params['ack_irrecoverable'])) {
$this->status(error('Tick the box acknowledging that losing the repository passphrase makes the backups permanently irrecoverable, then create the repository.'));
return;
}
if (!empty($params['gen_passphrase'])) {
// gen-passphrase both generates AND persists BORG_PASSPHRASE, so
// drop any pasted value from this postback to avoid stomping it.
$this->borg_gen_passphrase();
unset($params['BORG_PASSPHRASE']);
}
$this->persistRepo($params);
$ret = $this->borg_init_repo();
if ($ret === false) {
$this->status(false);
return;
}
// Show the recovery key exactly once, now, with a download.
$this->justCreated = true;
$bundle = $this->borg_key_bundle();
$this->createdBundle = is_string($bundle) ? $bundle : '';
$this->status(true);
}
}

View file

@ -0,0 +1,550 @@
@php
$cfg = $Page->config();
$info = $Page->repoInfo();
$locked = $Page->repoConnected();
$sites = $Page->sites();
$withSnaps = $Page->sitesWithSnapshots();
$custom = $Page->customPaths($cfg);
@endphp
<style>
.borg-app { max-width: 960px; margin: 0 auto; padding: 0 1rem; }
.borg-app h2 { margin-top: 1.75rem; padding-top: .75rem; border-top: 1px solid #e3e3e3; }
.borg-app fieldset { border: 1px solid #e3e3e3; border-radius: 4px; padding: .75rem 1rem; margin-bottom: .75rem; }
.borg-app legend { font-size: .95rem; font-weight: 600; width: auto; padding: 0 .4rem; }
.borg-locked { opacity: .55; }
.borg-summary { font-size: 1.05rem; }
.borg-dual { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.borg-dual .box { flex: 1 1 260px; }
.borg-dual .box label { font-weight: 600; display: block; margin-bottom: .25rem; }
.borg-dual select { width: 100%; min-height: 190px; }
.borg-dual .mid { display: flex; flex-direction: column; gap: .5rem; }
.borg-app .status-details { display: none; }
.borg-keybox { border: 2px solid #d9534f; border-radius: 4px; }
.borg-keybox textarea { font-family: monospace; font-size: .85rem; }
.borg-intro { margin-bottom: .5rem; }
.borg-nav { position: sticky; top: 0; z-index: 10; background: var(--body-bg, #fff); border-bottom: 1px solid #e3e3e3; padding: .55rem 0; margin: 0 0 1rem; display: flex; flex-wrap: wrap; gap: .35rem; }
.borg-nav a { display: inline-block; padding: .2rem .7rem; border-radius: 999px; background: #eef0f3; color: #2a2a2a; text-decoration: none; font-size: .82rem; white-space: nowrap; transition: background .15s ease, color .15s ease; }
.borg-nav a:hover, .borg-nav a:focus { background: #007bff; color: #fff; }
.borg-app fieldset legend { font-size: .9rem; }
.borg-app .form-row { gap: .5rem; }
.borg-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 9999; display: none; align-items: center; justify-content: center; }
.borg-overlay.show { display: flex; }
.borg-overlay .box { background: #fff; color: #222; padding: 1.5rem 2rem; border-radius: 8px; text-align: center; max-width: 90%; box-shadow: 0 8px 32px rgba(0,0,0,.35); }
.borg-overlay .spin { width: 2.2rem; height: 2.2rem; border: 3px solid #d5d8dc; border-top-color: #007bff; border-radius: 50%; margin: 0 auto .8rem; animation: borg-spin .8s linear infinite; }
.borg-overlay strong { display: block; margin-bottom: .25rem; }
@keyframes borg-spin { to { transform: rotate(360deg); } }
@media (prefers-color-scheme: dark) { .borg-overlay .box { background: #23272b; color: #eee; } }
</style>
<div class="borg-app">
<div class="borg-overlay" id="borg-overlay" role="alert" aria-live="assertive">
<div class="box">
<div class="spin"></div>
<strong id="borg-overlay-msg">Working</strong>
<p class="text-muted mb-0"><small>Running a borg operation. This can take a while for large repositories; please do not close or reload the page.</small></p>
</div>
</div>
@if ($Page->justCreated())
{{-- One-time recovery key panel, shown ONLY immediately after creation. --}}
<div class="alert alert-warning borg-keybox">
<h3 class="mt-0">Save your repository recovery key now</h3>
<p><strong>This is shown only once.</strong> If this passphrase/key is lost, your encrypted
backups are <strong>permanently irrecoverable</strong>, no matter how many copies of the
data exist. Download it and store it somewhere safe and <strong>off this server</strong>.</p>
<textarea id="borg-key-bundle" class="form-control" rows="12" readonly onclick="this.select()">{{ $Page->createdBundle() }}</textarea>
<button type="button" class="btn btn-danger mt-2" id="borg-download-key">Download recovery key</button>
<p class="mt-2 mb-0"><small>Reload the page once you have saved it; it will not be shown again.</small></p>
</div>
@endif
<p class="text-muted borg-intro">Configure the repository, choose what to back up, schedule and run backups, and restore sites, databases, or whole accounts. Every action here is also available on the CLI as <code>cpcmd borg:&lt;verb&gt;</code>.</p>
<nav class="borg-nav" id="borg-nav" aria-label="Sections"></nav>
{{-- ============================ REPOSITORY ============================ --}}
<h2>Repository</h2>
<p class="borg-summary">
@if ($info['connected'])
<span class="badge badge-success">connected</span>
@if($info['location']) at <code>{{ $info['location'] }}</code>@endif
@else
<span class="badge badge-secondary">no repository configured</span>
@endif
<button type="button" class="btn btn-link btn-sm" id="borg-toggle-status">Show details</button>
</p>
<pre class="status-details" id="borg-status-details">{{ $Page->repoStatus() }}</pre>
@if ($locked)
<div class="form-group">
<label><input type="checkbox" id="borg-override"> Reconfigure repository (unlock the settings below)</label>
</div>
@endif
<form method="post" class="repo-form" id="borg-repo-form">
<fieldset @if($locked) class="borg-locked borg-lockable" @endif>
<legend>Repository</legend>
<p class="text-muted mb-1"><small>There is only one repository type: a local path or an <code>ssh://</code> URL. There is no server mode and no SFTP key upload; SSH transport (if any) uses the host's own SSH configuration plus the options below.</small></p>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Repository</label>
<div class="col-sm-6"><input type="text" name="BORG_REPO" class="form-control borg-lock-field" value="{{ $cfg['BORG_REPO'] ?? '' }}" placeholder="/mnt/external/backups/apiscp-borg or ssh://borg@backup.example.net:22/./apiscp-borg" @if($locked) disabled @endif></div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Passphrase</label>
<div class="col-sm-6"><input type="password" name="BORG_PASSPHRASE" class="form-control borg-lock-field" value="{{ isset($cfg['BORG_PASSPHRASE']) ? '********' : '' }}" @if($locked) disabled @endif></div>
</div>
<div class="form-group row">
<div class="col-sm-9 offset-sm-3"><label><input type="checkbox" name="gen_passphrase" value="1" class="borg-lock-field" @if($locked) disabled @endif> Generate a strong passphrase (used when creating a repository)</label></div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Encryption</label>
<div class="col-sm-6">
<select name="BORG_ENCRYPTION" class="form-control borg-lock-field" @if($locked) disabled @endif>
@foreach (['repokey-blake2' => 'repokey-blake2 (key stored in the repository)', 'keyfile-blake2' => 'keyfile-blake2 (key stored locally, back it up separately)'] as $val => $lbl)
<option value="{{ $val }}" @if(($cfg['BORG_ENCRYPTION'] ?? 'repokey-blake2') === $val) selected @endif>{{ $lbl }}</option>
@endforeach
</select>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">SSH options (BORG_RSH)</label>
<div class="col-sm-6"><input type="text" name="BORG_RSH" class="form-control borg-lock-field" value="{{ $cfg['BORG_RSH'] ?? '' }}" placeholder="ssh -o BatchMode=yes -i /root/.ssh/borg_key" @if($locked) disabled @endif></div>
</div>
<div class="form-group row">
<div class="col-sm-9 offset-sm-3">
<button type="submit" name="save_repo" value="1" class="btn btn-primary borg-lock-field" @if($locked) disabled @endif>Save settings</button>
<button type="submit" name="check_access" value="1" class="btn btn-secondary">Check access</button>
</div>
</div>
<div class="form-group row borg-create-row">
<div class="col-sm-9 offset-sm-3">
<p class="text-muted mb-1"><small>Borg encrypts the repository with a passphrase. The recovery key is shown once, immediately after creation.</small></p>
<label class="d-block text-danger"><input type="checkbox" name="ack_irrecoverable" id="borg-ack" value="1" class="borg-lock-field" @if($locked) disabled @endif> I understand that if the repository passphrase/key is lost, the backups are permanently irrecoverable.</label>
<button type="submit" name="do_create" value="1" id="borg-create-btn" class="btn btn-success borg-lock-field" @if($locked) disabled @endif>Initialise repository</button>
</div>
</div>
</fieldset>
</form>
{{-- ========================= WHAT TO BACK UP ========================= --}}
<h2>What to back up</h2>
<form method="post">
<div class="borg-dual">
<div class="box">
<label>Available</label>
<select multiple id="borg-available">
@foreach ($sites as $id => $domain)
@unless ($Page->siteIncluded($id, $cfg))
<option value="{{ $id }}">{{ $id }} - {{ $domain }}</option>
@endunless
@endforeach
@unless ($Page->systemIncluded($cfg))
<option value="__system__">System information</option>
@endunless
</select>
</div>
<div class="mid">
<button type="button" class="btn btn-sm btn-secondary" id="borg-add">&rarr;</button>
<button type="button" class="btn btn-sm btn-secondary" id="borg-remove">&larr;</button>
</div>
<div class="box">
<label>Backing up</label>
<select multiple name="backing[]" id="borg-backing">
@foreach ($sites as $id => $domain)
@if ($Page->siteIncluded($id, $cfg))
<option value="{{ $id }}">{{ $id }} - {{ $domain }}</option>
@endif
@endforeach
@if ($Page->systemIncluded($cfg))
<option value="__system__">System information</option>
@endif
@foreach ($custom as $p)
<option value="path:{{ $p }}">path: {{ $p }}</option>
@endforeach
</select>
</div>
</div>
<div class="form-group mt-2">
<div class="input-group" style="max-width:520px;">
<input type="text" name="new_path" class="form-control" placeholder="/absolute/path to add">
<div class="input-group-append">
<button type="submit" name="add_path" value="1" class="btn btn-outline-secondary">Add custom path</button>
</div>
</div>
</div>
<div class="form-group">
<label><input type="checkbox" name="backup_databases" value="1" @if($Page->databasesIncluded($cfg)) checked @endif> Also back up site databases</label>
</div>
<button type="submit" name="save_selection" value="1" id="borg-save-selection" class="btn btn-primary">Save selection</button>
</form>
{{-- ====================== RETENTION AND EXCLUDES ===================== --}}
<h2>Retention and excludes</h2>
<p class="text-muted"><small>Retention is applied by <code>borg prune</code>, run automatically after every backup and on demand below. Leave a box blank for "no limit" on that bucket.</small></p>
<form method="post">
<fieldset>
<legend>Keep how many archives</legend>
<div class="form-row">
<div class="form-group col-auto">
<label class="mb-0"><small>Within</small></label>
<input type="text" name="RETENTION_KEEP_WITHIN" class="form-control form-control-sm" style="width:6rem;" value="{{ $cfg['RETENTION_KEEP_WITHIN'] ?? '' }}" placeholder="7d">
</div>
@foreach (['RETENTION_KEEP_DAILY' => 'Daily', 'RETENTION_KEEP_WEEKLY' => 'Weekly', 'RETENTION_KEEP_MONTHLY' => 'Monthly', 'RETENTION_KEEP_ANNUAL' => 'Annual'] as $key => $label)
<div class="form-group col-auto">
<label class="mb-0"><small>{{ $label }}</small></label>
<input type="number" min="0" name="{{ $key }}" class="form-control form-control-sm" style="width:6rem;" value="{{ $cfg[$key] ?? '' }}">
</div>
@endforeach
</div>
<p class="text-muted mb-0"><small>"Within" is a duration such as <code>7d</code>, <code>4w</code>, <code>6m</code> or <code>1y</code> (H, d, w, m, y). The others are archive counts.</small></p>
</fieldset>
<fieldset>
<legend>Exclude patterns</legend>
<p class="text-muted mb-1"><small>One glob per line (for example <code>*.log</code>, <code>cache/</code>, <code>node_modules/</code>). The restore staging area <code>/.borg-restore</code> is always excluded.</small></p>
<textarea name="BACKUP_EXCLUDES" class="form-control" rows="4">{{ str_replace(',', "\n", (string)($cfg['BACKUP_EXCLUDES'] ?? '')) }}</textarea>
</fieldset>
<button type="submit" name="save_policy" value="1" class="btn btn-primary">Save retention and excludes</button>
<button type="submit" name="run_prune" value="1" class="btn btn-secondary">Prune now</button>
</form>
{{-- ============================== RUN =============================== --}}
<h2>Run a backup</h2>
<p>
@if ($Page->isRunning())
<span class="badge badge-info">a backup is currently running</span>
@else
<span class="badge badge-secondary">idle</span>
@endif
</p>
<form method="post" class="form-inline">
<select name="run_target" id="borg-run-target" class="form-control mr-2">
<option value="all">Everything (selected sites + system)</option>
<option value="site">One site</option>
<option value="path">A custom path</option>
</select>
<select name="run_site" id="borg-run-site" class="form-control mr-2" style="display:none;">
@foreach ($sites as $id => $domain)
<option value="{{ $id }}">{{ $id }} - {{ $domain }}</option>
@endforeach
</select>
<input type="text" name="run_path" id="borg-run-path" class="form-control mr-2" style="display:none;" placeholder="/absolute/path">
<button type="submit" name="run" value="1" class="btn btn-primary" @if($Page->isRunning()) disabled @endif>Run now</button>
</form>
{{-- ==================== SCHEDULE AND MAINTENANCE ===================== --}}
<h2>Schedule and maintenance</h2>
<form method="post" class="form-inline mb-2">
<label class="mr-2">Run the scheduled backup daily at</label>
<input type="time" name="schedule" class="form-control mr-2" value="{{ $cfg['BACKUP_SCHEDULE'] ?? '03:30' }}">
<button type="submit" name="save_schedule" value="1" class="btn btn-primary">Save schedule</button>
<span class="ml-2 text-muted"><small>Or set BACKUP_SCHEDULE to a systemd OnCalendar expression on the CLI.</small></span>
</form>
<p class="text-muted mb-1"><small>Maintenance compacts the repository (borg compact); verification (borg check) confirms repository and archive consistency. Both run in the background.</small></p>
<form method="post" class="form-inline">
<button type="submit" name="run_maintenance" value="1" class="btn btn-secondary mr-3">Run maintenance now</button>
<label class="mr-2"><input type="checkbox" name="verify_data" value="1"> Also verify file contents (slower)</label>
<button type="submit" name="run_verify" value="1" class="btn btn-secondary">Verify now</button>
</form>
{{-- ============================ NOTIFICATIONS ======================== --}}
<h2>Notifications</h2>
<p class="text-muted mb-1"><small>Email a report of each backup run. Requires a working mail system on this server.</small></p>
<form method="post" class="form-inline">
<label class="mr-2">Email reports to</label>
<input type="email" name="NOTIFY_EMAIL" class="form-control mr-2" value="{{ $cfg['NOTIFY_EMAIL'] ?? '' }}" placeholder="you@example.com" style="min-width:230px;">
<select name="NOTIFY_ON" class="form-control mr-2">
@foreach (['failure' => 'On failure only', 'always' => 'Every run', 'never' => 'Never'] as $val => $lbl)
<option value="{{ $val }}" @if(($cfg['NOTIFY_ON'] ?? 'failure') === $val) selected @endif>{{ $lbl }}</option>
@endforeach
</select>
<input type="text" name="NOTIFY_FROM" class="form-control mr-2" value="{{ $cfg['NOTIFY_FROM'] ?? '' }}" placeholder="sender override (optional)" style="min-width:200px;">
<button type="submit" name="save_notify" value="1" class="btn btn-primary mr-2">Save</button>
<button type="submit" name="test_notify" value="1" class="btn btn-secondary">Send test email</button>
</form>
{{-- ======================= REPOSITORY KEY BACKUP ===================== --}}
<h2>Repository key backup</h2>
<p>
A Borg repository is encrypted. If the passphrase (and, for keyfile mode,
the key) is lost the backups are unrecoverable. Back it up once and store
it off this machine.
Status:
@if ($Page->keyBackedUp())
<span class="badge badge-success">key backed up</span>
@else
<span class="badge badge-danger">not backed up yet</span>
@endif
</p>
<form method="post" class="form-inline">
<input type="text" name="dest" class="form-control mr-2" value="{{ $cfg['KEY_BACKUP_DIR'] ?? '' }}" placeholder="/root/apiscp-borg-keys">
<button type="submit" name="backup_key" value="1" class="btn btn-warning mr-2">Back up key now</button>
<label><input type="checkbox" name="force" value="1"> force (redo)</label>
</form>
{{-- ============================= RESTORE ============================= --}}
<h2>Restore</h2>
@php $restoreKind = $Page->restoreMode(); @endphp
<p class="text-muted"><small>Borg does not yet support point-in-time archive selection or a subpath picker in this release: every restore uses the newest matching archive and restores the whole site, account, database, or system snapshot. Archive dates below are shown for information only.</small></p>
<div class="form-group" style="max-width:560px;">
<label class="font-weight-bold">What would you like to restore?</label>
<select id="borg-restore-kind" class="form-control" data-initial="{{ $restoreKind }}">
<option value="files">A site's files (newest archive)</option>
<option value="account">An entire account (files and all databases)</option>
<option value="database">A single database</option>
<option value="system">System information (/etc, /opt, and configured extras)</option>
</select>
</div>
<div class="borg-restore-panel" data-kind="files">
<h3 class="mt-3">Site files</h3>
@if (!count($sites))
<p>No sites found.</p>
@else
<form method="post" class="form-inline">
<select name="site" class="form-control mr-2">
@foreach ($sites as $id => $domain)
@php $has = in_array($id, $withSnaps, true); @endphp
<option value="{{ $id }}" @if($Page->selectedSite() === $id) selected @endif @unless($has) disabled @endunless>
{{ $id }} - {{ $domain }}@unless($has) (no archives)@endunless
</option>
@endforeach
</select>
<button type="submit" name="list_snaps" value="1" class="btn btn-secondary">Show backups</button>
</form>
@if ($Page->selectedSite() !== '')
@php $snaps = $Page->selectedSnaps(); @endphp
<h3>{{ $Page->selectedSite() }}</h3>
@if (!count($snaps))
<p><span class="badge badge-warning">no archives</span> This site has no backups yet.</p>
@else
<p class="text-muted"><small>Archives taken (newest first): {{ implode(', ', array_map(fn($s) => $Page->fmtTime($s['time']), array_slice($snaps, 0, 6))) }}@if(count($snaps) > 6), &hellip;@endif. Restore uses the newest.</small></p>
<form method="post">
<input type="hidden" name="site" value="{{ $Page->selectedSite() }}">
<div class="form-group row">
<label class="col-sm-2 col-form-label">Restore into</label>
<div class="col-sm-6"><input type="text" name="target" class="form-control" value="/var/tmp/restore-{{ $Page->selectedSite() }}"></div>
</div>
<div class="form-group row">
<div class="col-sm-10 offset-sm-2">
<button type="submit" name="restore" value="1" class="btn btn-primary">Restore newest archive</button>
</div>
</div>
</form>
@endif
@endif
@endif
</div>{{-- /files panel --}}
<div class="borg-restore-panel" data-kind="account">
<h3 class="mt-3">Entire account</h3>
<p class="text-muted"><small>Restore a whole account in one step: its newest files plus all of its databases (newest backup) into a staging directory. Databases land under <code>databases/</code>; review and import them as needed.</small></p>
@if (!count($sites))
<p>No sites found.</p>
@else
<form method="post" class="form-inline">
<select name="acct_site" class="form-control mr-2">
@foreach ($sites as $id => $domain)
@php $has = in_array($id, $withSnaps, true); @endphp
<option value="{{ $id }}" @unless($has) disabled @endunless>{{ $id }} - {{ $domain }}@unless($has) (no archives)@endunless</option>
@endforeach
</select>
<input type="text" name="target" class="form-control mr-2" value="/var/tmp/restore-account" style="min-width:280px;">
<button type="submit" name="restore_account" value="1" class="btn btn-primary">Restore entire account</button>
</form>
@endif
</div>{{-- /account panel --}}
<div class="borg-restore-panel" data-kind="system">
<h3 class="mt-3">System information</h3>
<p class="text-muted"><small>Restores the newest backed-up system archive into a staging directory (non-destructive).</small></p>
<form method="post" class="form-inline">
<label class="mr-2">Restore into</label>
<input type="text" name="target" class="form-control mr-2" value="/var/tmp/restore-system" style="min-width:320px;">
<button type="submit" name="restore_system" value="1" class="btn btn-primary">Restore system</button>
</form>
</div>{{-- /system panel --}}
<div class="borg-restore-panel" data-kind="database">
<h3 class="mt-3">Databases</h3>
@if (!count($sites))
<p>No sites found.</p>
@else
<form method="post" class="form-inline">
<select name="site" class="form-control mr-2">
@foreach ($sites as $id => $domain)
<option value="{{ $id }}" @if($Page->dbSite() === $id) selected @endif>{{ $id }} - {{ $domain }}</option>
@endforeach
</select>
<button type="submit" name="list_dbs" value="1" class="btn btn-secondary">List databases</button>
</form>
@php $dbs = $Page->dbList(); @endphp
@if ($Page->dbSite() !== '')
<h3>{{ $Page->dbSite() }}</h3>
@php $dbBackups = $Page->dbBackups(); @endphp
<p class="text-muted">
@if (count($dbBackups))
<small>Database backups taken (newest first):
{{ implode(', ', array_map(fn($b) => $Page->fmtTime($b['time']), array_slice($dbBackups, 0, 6))) }}@if(count($dbBackups) > 6), &hellip;@endif.
Restores and imports use the newest backup.</small>
@else
<small>No dated database backups captured yet for this site.</small>
@endif
</p>
@if (!count($dbs))
<p><span class="badge badge-warning">no databases</span> No databases found for this site.</p>
@else
<p class="borg-db-warn"><strong>Warning:</strong> importing overwrites the LIVE database and is destructive. Restoring a dump to staging is safe.</p>
<table class="table table-sm borg-db-table">
<thead>
<tr><th>Engine</th><th>Database</th><th>Restore dump to staging</th><th>Import into live database</th></tr>
</thead>
<tbody>
@foreach ($dbs as $row)
@php $engine = $row['engine'] ?? ''; $db = $row['db'] ?? ''; $staging = '/var/tmp/restore-db-' . $db; @endphp
<tr>
<td>{{ $engine }}</td>
<td>{{ $db }}</td>
<td>
<form method="post" class="borg-db-form">
<input type="hidden" name="site" value="{{ $Page->dbSite() }}">
<input type="hidden" name="engine" value="{{ $engine }}">
<input type="hidden" name="db" value="{{ $db }}">
<input type="text" name="target" class="form-control form-control-sm mb-1" value="{{ $staging }}">
<button type="submit" name="restore_db" value="1" class="btn btn-sm btn-outline-secondary">Restore dump to staging</button>
</form>
</td>
<td>
<form method="post" class="borg-db-form" onsubmit="return confirm('Overwrite the LIVE database {{ $db }} with the newest backup? This is destructive and cannot be undone.');">
<input type="hidden" name="site" value="{{ $Page->dbSite() }}">
<input type="hidden" name="engine" value="{{ $engine }}">
<input type="hidden" name="db" value="{{ $db }}">
<label class="d-block"><input type="checkbox" name="confirm_import" value="1"> I understand this overwrites the live database</label>
<button type="submit" name="import_db" value="1" class="btn btn-sm btn-danger">Import newest backup into live database</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
@endif
@endif
@endif
</div>{{-- /database panel --}}
</div>
<script>
(function () {
// Keep the viewport where it was across a postback reload (so submitting a
// form does not jump the page back to the top).
try {
var savedY = sessionStorage.getItem('borgScroll');
if (savedY !== null) { window.scrollTo(0, parseInt(savedY, 10) || 0); sessionStorage.removeItem('borgScroll'); }
window.addEventListener('beforeunload', function () {
try { sessionStorage.setItem('borgScroll', String(window.scrollY || window.pageYOffset || 0)); } catch (e) {}
});
} catch (e) {}
// On any form submit: show a blocking "running" overlay and guard against a
// double submit. We do NOT disable the button (that would drop its name from
// the POST); the overlay blocks further clicks and a flag ignores repeats.
var overlay = document.getElementById('borg-overlay'),
overlayMsg = document.getElementById('borg-overlay-msg');
document.querySelectorAll('.borg-app form').forEach(function (f) {
f.addEventListener('submit', function (e) {
if (e.defaultPrevented) return;
if (f.dataset.borgBusy === '1') { e.preventDefault(); return; }
f.dataset.borgBusy = '1';
var btn = e.submitter || f.querySelector('button[type="submit"], button:not([type])');
var label = btn ? (btn.textContent || '').trim().replace(/\s+/g, ' ') : 'Working';
if (overlayMsg) overlayMsg.textContent = label || 'Working';
if (overlay) overlay.classList.add('show');
});
});
// Build an in-page nav from the section headings for quick jumping.
var nav = document.getElementById('borg-nav');
if (nav) {
document.querySelectorAll('.borg-app > h2').forEach(function (h) {
if (!h.id) h.id = 'sec-' + h.textContent.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
var a = document.createElement('a');
a.href = '#' + h.id;
a.textContent = h.textContent.trim();
nav.appendChild(a);
});
}
var tgl = document.getElementById('borg-toggle-status'),
det = document.getElementById('borg-status-details');
if (tgl && det) tgl.addEventListener('click', function () {
det.style.display = (det.style.display === 'block') ? 'none' : 'block';
});
var ov = document.getElementById('borg-override');
if (ov) ov.addEventListener('change', function () {
document.querySelectorAll('.borg-lock-field').forEach(function (f) { f.disabled = !ov.checked; });
document.querySelectorAll('.borg-lockable').forEach(function (f) {
f.classList.toggle('borg-locked', !ov.checked);
});
});
function move(from, to) {
var a = document.getElementById(from), b = document.getElementById(to);
if (!a || !b) return;
Array.prototype.slice.call(a.selectedOptions).forEach(function (o) { b.appendChild(o); });
}
var add = document.getElementById('borg-add'), rem = document.getElementById('borg-remove');
if (add) add.addEventListener('click', function () { move('borg-available', 'borg-backing'); });
if (rem) rem.addEventListener('click', function () { move('borg-backing', 'borg-available'); });
var saveBtn = document.getElementById('borg-save-selection');
if (saveBtn) saveBtn.addEventListener('click', function () {
var b = document.getElementById('borg-backing');
if (b) Array.prototype.slice.call(b.options).forEach(function (o) { o.selected = true; });
});
var dlBtn = document.getElementById('borg-download-key');
if (dlBtn) dlBtn.addEventListener('click', function () {
var ta = document.getElementById('borg-key-bundle');
if (!ta) return;
var blob = new Blob([ta.value], { type: 'text/plain' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'apiscp-borg-recovery-key.txt';
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(a.href);
});
var ack = document.getElementById('borg-ack'), createBtn = document.getElementById('borg-create-btn');
function syncAck() { if (createBtn && ack) createBtn.disabled = !ack.checked; }
if (ack && createBtn) { ack.addEventListener('change', syncAck); syncAck(); }
// Restore workflow: one section, show the chosen sub-panel.
var rk = document.getElementById('borg-restore-kind');
function syncRestore() {
var v = rk ? rk.value : 'files';
document.querySelectorAll('.borg-restore-panel').forEach(function (p) {
p.style.display = (p.getAttribute('data-kind') === v) ? '' : 'none';
});
}
if (rk) {
var initKind = rk.getAttribute('data-initial');
if (initKind) rk.value = initKind;
rk.addEventListener('change', syncRestore);
syncRestore();
}
var rt = document.getElementById('borg-run-target');
function syncRun() {
var v = rt ? rt.value : 'all';
var s = document.getElementById('borg-run-site'), p = document.getElementById('borg-run-path');
if (s) s.style.display = (v === 'site') ? '' : 'none';
if (p) p.style.display = (v === 'path') ? '' : 'none';
}
if (rt) { rt.addEventListener('change', syncRun); syncRun(); }
})();
</script>

View file

@ -0,0 +1,14 @@
vars:
title: My Borg Backups
tagline: >-
Restore your own site's files and databases from backup, and run a backup of
your site on demand.
help: >-
This panel lets you recover your own account from its Borg backups.
Restores are placed into your own file space (under
<code>/.borg-restore/</code>) so you can retrieve them with the File
Manager or SFTP. Every restore uses the newest backup archive: there is
no point-in-time selection or file picker in this release. Restoring a
database dump is safe; importing it back over your live database
overwrites the current data and cannot be undone. You can only ever see
and restore your own site.

View file

@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
/**
* apiscp-borg site-owner GUI app controller ("My Borg Backups").
*
* Installed (upgrade-safe) at config/custom/apps/myborgbackups/myborgbackups.php.
* Runs in the SITE panel and is scoped entirely to the signed-in account's
* own site: every action calls a PRIVILEGE_SITE borg:my_* / *_my_* verb, and
* those verbs derive the site from the ApisCP auth context. This page never
* sends a site identifier, so a site owner can only ever reach their own
* backups. There is deliberately no repository configuration, no system
* paths, no key backup, and no visibility of other accounts here.
*
* @license MIT
*/
namespace apps\myborgbackups;
class Page extends \Page_Container
{
/** @var array archives (file trees) for this site, informational only */
protected $snaps = [];
/** @var array databases for this site: [{engine,db}, ...] */
protected $dbs = [];
/** @var string in-site path produced by the last restore, for display */
protected $result = '';
public function __construct()
{
parent::__construct();
}
/**
* Set the postback status banner. Page_Container::bind() takes its
* argument by reference, so a literal, ternary, or function result cannot
* be passed to it directly (fatal "could not be passed by reference" on
* PHP 8.1+). Route every status through a local variable here.
*
* @param mixed $status truthy for success, false for failure
* @return void
*/
private function status($status): void
{
$s = $status;
$this->bind($s);
}
/* ---- view helpers (read-only, called from the Blade template) ---- */
/** This account's own site + domain (from the auth context). */
public function mySite(): array
{
$r = $this->borg_my_site();
return is_array($r) ? $r : ['site' => '', 'domain' => ''];
}
public function snaps(): array { return $this->snaps; }
public function dbs(): array { return $this->dbs; }
public function result(): string { return $this->result; }
/** Dated database archives for this site, newest first (informational). */
public function dbBackups(): array
{
$r = $this->borg_my_database_backups();
return is_array($r) ? $r : [];
}
/**
* Format an ISO-8601 archive time into a readable UTC string, e.g.
* "2026-07-24 14:19 UTC". Falls back to the raw value if unparseable.
*/
public function fmtTime(?string $iso): string
{
$iso = (string)$iso;
$ts = strtotime($iso);
return $ts ? gmdate('Y-m-d H:i', $ts) . ' UTC' : $iso;
}
/* ---- actions (postback) ---- */
public function on_postback($params)
{
if (isset($params['list_snaps'])) {
$this->loadSnaps();
} else if (isset($params['restore'])) {
$this->doRestore();
} else if (isset($params['restore_account'])) {
$ret = $this->borg_restore_my_account();
if ($ret === false) {
$this->status(false);
} else {
$this->result = (string)$ret;
info('Whole account restored to %s in your own file space (files, and databases under databases/).', (string)$ret);
}
} else if (isset($params['list_dbs'])) {
$this->loadDatabases();
} else if (isset($params['restore_db'])) {
$this->doRestoreDatabase($params);
} else if (isset($params['import_db'])) {
$this->doImportDatabase($params);
} else if (isset($params['run'])) {
$ret = $this->borg_run_my_backup();
$this->status($ret === false ? false : true);
}
}
private function loadSnaps(): void
{
$r = $this->borg_my_snapshots();
$this->snaps = is_array($r) ? $r : [];
}
/** Restore the caller's own files (newest archive; no point-in-time or
* subpath selection is available in this release, see the module). */
private function doRestore(): void
{
$ret = $this->borg_restore_my_files();
if ($ret === false) {
$this->status(false);
} else {
$this->result = (string)$ret;
info('Restored to %s in your own file space. Open your file manager to retrieve it.', (string)$ret);
}
// keep the archive list visible after a restore
$this->loadSnaps();
}
private function loadDatabases(): void
{
$r = $this->borg_my_databases();
$this->dbs = is_array($r) ? $r : [];
}
private function doRestoreDatabase($params): void
{
if (empty($params['engine']) || empty($params['db'])) {
$this->status(error('engine and database are required'));
} else {
$ret = $this->borg_restore_my_database((string)$params['engine'], (string)$params['db']);
if ($ret === false) {
$this->status(false);
} else {
$this->result = (string)$ret;
info('Database dump restored to %s in your own file space.', (string)$ret);
}
}
$this->loadDatabases();
}
private function doImportDatabase($params): void
{
if (empty($params['engine']) || empty($params['db'])) {
$this->status(error('engine and database are required'));
} else if (empty($params['confirm_import'])) {
$this->status(error('tick confirm to import over your live database'));
} else {
$ret = $this->borg_import_my_database((string)$params['engine'], (string)$params['db'], true);
$this->status($ret === false ? false : true);
}
$this->loadDatabases();
}
}

View file

@ -0,0 +1,160 @@
@php
$me = $Page->mySite();
$site = $me['site'] ?? '';
$domain = $me['domain'] ?? '';
$snaps = $Page->snaps();
$dbs = $Page->dbs();
$result = $Page->result();
@endphp
<style>
.borg-app { max-width: 900px; margin: 0 auto; padding: 0 1rem; }
.borg-app h2 { margin-top: 1.75rem; padding-top: .75rem; border-top: 1px solid #e3e3e3; }
.borg-db-warn { color: #8a4b00; }
.borg-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 9999; display: none; align-items: center; justify-content: center; }
.borg-overlay.show { display: flex; }
.borg-overlay .box { background: #fff; color: #222; padding: 1.5rem 2rem; border-radius: 8px; text-align: center; max-width: 90%; box-shadow: 0 8px 32px rgba(0,0,0,.35); }
.borg-overlay .spin { width: 2.2rem; height: 2.2rem; border: 3px solid #d5d8dc; border-top-color: #007bff; border-radius: 50%; margin: 0 auto .8rem; animation: borg-spin .8s linear infinite; }
.borg-overlay strong { display: block; margin-bottom: .25rem; }
@keyframes borg-spin { to { transform: rotate(360deg); } }
@media (prefers-color-scheme: dark) { .borg-overlay .box { background: #23272b; color: #eee; } }
</style>
<div class="borg-app">
<div class="borg-overlay" id="borg-overlay" role="alert" aria-live="assertive">
<div class="box">
<div class="spin"></div>
<strong id="borg-overlay-msg">Working</strong>
<p class="text-muted mb-0"><small>Running your request. This can take a while; please do not close or reload the page.</small></p>
</div>
</div>
@if ($site === '')
<div class="alert alert-warning">
Your account's backup context could not be determined. Please contact
your administrator.
</div>
@else
<p class="lead">
Backups for <strong>{{ $domain ?: $site }}</strong>.
Restores are placed into your own files under
<code>/.borg-restore/</code>; retrieve them with the File Manager or SFTP.
</p>
<p class="text-muted"><small>Every restore uses the newest backup: there is no point-in-time selection or file picker in this release.</small></p>
@if ($result !== '')
<div class="alert alert-success">
Last restore is available in your files at <code>{{ $result }}</code>.
</div>
@endif
{{-- ============================== RESTORE FILES ============================== --}}
<h2>Restore files</h2>
<form method="post" class="form-inline mb-2">
<button type="submit" name="list_snaps" value="1" class="btn btn-secondary">Show my backups</button>
</form>
@if (count($snaps))
<p class="text-muted"><small>Backups taken (newest first): {{ implode(', ', array_map(fn($s) => $Page->fmtTime($s['time']), array_slice($snaps, 0, 6))) }}@if(count($snaps) > 6), &hellip;@endif.</small></p>
<form method="post">
<button type="submit" name="restore" value="1" class="btn btn-primary">Restore newest backup into my files (with permissions)</button>
</form>
@else
<p class="text-muted"><small>Click "Show my backups" to list the backups taken for your site.</small></p>
@endif
{{-- ============================== RESTORE EVERYTHING ============================== --}}
<h2>Restore my entire account</h2>
<p class="text-muted"><small>Restore everything in one step: all your files (with permissions) plus all your databases (newest backup) into your own file space under <code>/.borg-restore/account-&lt;date&gt;/</code>. Databases land under <code>databases/</code> for you to review.</small></p>
<form method="post">
<button type="submit" name="restore_account" value="1" class="btn btn-primary">Restore my entire account</button>
</form>
{{-- ============================== DATABASES ============================== --}}
<h2>Databases</h2>
<form method="post" class="form-inline mb-2">
<button type="submit" name="list_dbs" value="1" class="btn btn-secondary">List my databases</button>
</form>
@if (count($dbs))
@php $dbBackups = $Page->dbBackups(); @endphp
@if (count($dbBackups))
<p class="text-muted"><small>Database backups taken (newest first):
{{ implode(', ', array_map(fn($b) => $Page->fmtTime($b['time']), array_slice($dbBackups, 0, 6))) }}@if(count($dbBackups) > 6), &hellip;@endif.
Restores and imports use the newest backup.</small></p>
@endif
<p class="borg-db-warn"><strong>Warning:</strong> importing overwrites your LIVE database and cannot be undone. Restoring a dump to your files is safe.</p>
<table class="table table-sm">
<thead>
<tr><th>Engine</th><th>Database</th><th>Restore dump to my files</th><th>Import into live database</th></tr>
</thead>
<tbody>
@foreach ($dbs as $row)
@php $engine = $row['engine'] ?? ''; $db = $row['db'] ?? ''; @endphp
<tr>
<td>{{ $engine }}</td>
<td>{{ $db }}</td>
<td>
<form method="post">
<input type="hidden" name="engine" value="{{ $engine }}">
<input type="hidden" name="db" value="{{ $db }}">
<button type="submit" name="restore_db" value="1" class="btn btn-sm btn-outline-secondary">Restore dump to my files</button>
</form>
</td>
<td>
<form method="post" onsubmit="return confirm('Overwrite your LIVE database {{ $db }} with the newest backup? This is destructive and cannot be undone.');">
<input type="hidden" name="engine" value="{{ $engine }}">
<input type="hidden" name="db" value="{{ $db }}">
<label class="d-block"><input type="checkbox" name="confirm_import" value="1"> I understand this overwrites my live database</label>
<button type="submit" name="import_db" value="1" class="btn btn-sm btn-danger">Import newest backup into live database</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
@else
<p class="text-muted"><small>Click "List my databases" to see the databases captured for your site.</small></p>
@endif
{{-- ============================== RUN A BACKUP ============================== --}}
<h2>Back up now</h2>
<p class="text-muted"><small>Run a backup of your site on demand (in addition to the scheduled backups).</small></p>
<form method="post">
<button type="submit" name="run" value="1" class="btn btn-primary">Back up my site now</button>
</form>
@endif
</div>
<script>
(function () {
// Keep the viewport where it was across a postback reload.
try {
var savedY = sessionStorage.getItem('borgMyScroll');
if (savedY !== null) { window.scrollTo(0, parseInt(savedY, 10) || 0); sessionStorage.removeItem('borgMyScroll'); }
window.addEventListener('beforeunload', function () {
try { sessionStorage.setItem('borgMyScroll', String(window.scrollY || window.pageYOffset || 0)); } catch (e) {}
});
} catch (e) {}
// Show a blocking "running" overlay on submit and guard against double submit.
// Skip if a prior handler (e.g. a confirm dialog) already cancelled the submit.
var overlay = document.getElementById('borg-overlay'),
overlayMsg = document.getElementById('borg-overlay-msg');
document.querySelectorAll('.borg-app form').forEach(function (f) {
f.addEventListener('submit', function (e) {
if (e.defaultPrevented) return;
if (f.dataset.borgBusy === '1') { e.preventDefault(); return; }
f.dataset.borgBusy = '1';
var btn = e.submitter || f.querySelector('button[type="submit"], button:not([type])');
var label = btn ? (btn.textContent || '').trim().replace(/\s+/g, ' ') : 'Working';
if (overlayMsg) overlayMsg.textContent = label || 'Working';
if (overlay) overlay.classList.add('show');
});
});
})();
</script>

21
src/hooks/addDomain.sh Normal file
View file

@ -0,0 +1,21 @@
#!/bin/sh
#
# apiscp-borg account hook: AFTER a domain/account is created.
#
# ApisCP calls this with the site identifier (siteN) as the first argument once
# the account exists. We kick off an initial backup of just this site (detached)
# so a brand-new account is protected immediately instead of waiting for the
# next scheduled run. Hooks cannot interrupt provisioning, so this always exits 0.
#
# Installed (upgrade-safe) at config/custom/hooks/addDomain.sh by install-layer2.sh.
set -u
_site="${1:-}"
case "$_site" in
site[0-9]*) ;;
*) exit 0 ;;
esac
command -v logger >/dev/null 2>&1 && logger -t apiscp-borg "hook addDomain: initial backup of $_site"
[ -x /usr/local/bin/borg-apiscp-repo ] && /usr/local/bin/borg-apiscp-repo run --site "$_site" >/dev/null 2>&1 || true
exit 0

21
src/hooks/deleteDomain.sh Normal file
View file

@ -0,0 +1,21 @@
#!/bin/sh
#
# apiscp-borg account hook: AFTER a domain/account is deleted.
#
# ApisCP calls this with the site identifier (siteN) as the first argument once
# the account is gone. We deliberately DO NOT purge the account's archives:
# deletion is exactly when a recoverable backup matters most, so the archives
# are retained per the repository's retention policy and any purge is a
# deliberate, manual (or retention-window) decision. We only record the deletion
# so an operator has an audit trail. Hooks cannot interrupt flow; always exit 0.
#
# Installed (upgrade-safe) at config/custom/hooks/deleteDomain.sh.
set -u
_site="${1:-}"
_rec="${APISCP_BORG_DELETED_LOG:-/var/lib/apiscp-borg/deleted-accounts.log}"
mkdir -p "$(dirname "$_rec")" 2>/dev/null || true
printf '%s\t%s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null || date)" "${_site:-unknown}" >> "$_rec" 2>/dev/null || true
command -v logger >/dev/null 2>&1 && logger -t apiscp-borg "hook deleteDomain: ${_site:-unknown} recorded; archives retained (purge is manual / retention-window)"
exit 0

View file

@ -0,0 +1,21 @@
#!/bin/sh
#
# apiscp-borg account hook: AFTER a domain/account is suspended.
#
# ApisCP calls this with the site identifier (siteN) as the first argument. A
# suspended account's files still exist on disk, so we take one final backup of
# the site to capture its state at suspension before anything is cleaned up.
# Hooks cannot interrupt flow, so this always exits 0.
#
# Installed (upgrade-safe) at config/custom/hooks/suspendDomain.sh.
set -u
_site="${1:-}"
case "$_site" in
site[0-9]*) ;;
*) exit 0 ;;
esac
command -v logger >/dev/null 2>&1 && logger -t apiscp-borg "hook suspendDomain: final backup of $_site"
[ -x /usr/local/bin/borg-apiscp-repo ] && /usr/local/bin/borg-apiscp-repo run --site "$_site" >/dev/null 2>&1 || true
exit 0

939
src/modules/borg.php Normal file
View file

@ -0,0 +1,939 @@
<?php
declare(strict_types=1);
/**
* apiscp-borg: native ApisCP module exposing the Borg backup engine.
*
* Installed as an addon module at lib/modules/surrogates/borg.php, which
* ApisCP git-ignores so it survives upgrades. Every verb is callable from
* the CLI as `cpcmd borg:<verb>` and from the GUI apps (apps/borg,
* apps/myborgbackups).
*
* Repository management and backup/restore are all here so the panel and
* CLI expose the same full set of options. The repository model is much
* simpler than the sibling kopia module: there are no backend "types", no
* server/sftp transports and no key upload, just BORG_REPO (a local path
* or an ssh:// URL), BORG_PASSPHRASE, BORG_ENCRYPTION and an optional
* BORG_RSH. "Snapshots" here are Borg archives, addressed by name.
*
* The heavy lifting lives in the standalone Layer 1 binaries so the engine
* keeps working independently of ApisCP; this module is a thin, audited
* wrapper that runs them as root via Util_Process.
*
* @license MIT
*/
class Borg_Module_Surrogate extends Module_Skeleton
{
const BACKUP_BIN = '/usr/local/bin/borg-apiscp-backup';
const RESTORE_BIN = '/usr/local/bin/borg-apiscp-restore';
const REPO_BIN = '/usr/local/bin/borg-apiscp-repo';
const CONFIG_FILE = '/etc/apiscp-borg/config';
const SERVICE = 'borg-apiscp-backup.service';
// Config keys the GUI/CLI may set. Anything else is rejected so a caller
// cannot inject arbitrary shell-sourced lines into the config file. This
// mirrors borg-apiscp-repo's own CONFIG_ALLOWED list exactly.
const ALLOWED_KEYS = [
'BORG_REPO', 'BORG_PASSPHRASE', 'BORG_ENCRYPTION', 'BORG_RSH', 'BORG_COMPRESSION',
'KEY_BACKUP_DIR', 'VIRTBASE', 'METRICS_DIR', 'RUN_DB_EXPORT',
'BACKUP_SITES', 'BACKUP_SYSTEM', 'BACKUP_PATHS', 'BACKUP_DATABASES', 'BACKUP_EXCLUDES',
'BACKUP_SCHEDULE', 'CAPTURE_META_SIDECAR',
'RETENTION_KEEP_WITHIN', 'RETENTION_KEEP_DAILY', 'RETENTION_KEEP_WEEKLY',
'RETENTION_KEEP_MONTHLY', 'RETENTION_KEEP_ANNUAL',
'NOTIFY_EMAIL', 'NOTIFY_ON', 'NOTIFY_FROM',
];
public $exportedFunctions = [
'*' => PRIVILEGE_NONE,
// Repository management and server-wide backup: appliance admin only.
'get_config' => PRIVILEGE_ADMIN,
'set_option' => PRIVILEGE_ADMIN,
'status' => PRIVILEGE_ADMIN,
'check_access' => PRIVILEGE_ADMIN,
'key_backed_up' => PRIVILEGE_ADMIN,
'backup_key' => PRIVILEGE_ADMIN,
'key_bundle' => PRIVILEGE_ADMIN,
'run' => PRIVILEGE_ADMIN,
'is_running' => PRIVILEGE_ADMIN,
'sites' => PRIVILEGE_ADMIN,
'site_snapshots' => PRIVILEGE_ADMIN,
'restore_site' => PRIVILEGE_ADMIN,
'init_repo' => PRIVILEGE_ADMIN,
'gen_passphrase' => PRIVILEGE_ADMIN,
'set_backup_selection' => PRIVILEGE_ADMIN,
'set_schedule' => PRIVILEGE_ADMIN,
'maintenance' => PRIVILEGE_ADMIN,
'verify' => PRIVILEGE_ADMIN,
'notify_test' => PRIVILEGE_ADMIN,
'prune' => PRIVILEGE_ADMIN,
'repo_info' => PRIVILEGE_ADMIN,
'validate_path' => PRIVILEGE_ADMIN,
'sites_with_snapshots' => PRIVILEGE_ADMIN,
'site_databases' => PRIVILEGE_ADMIN,
'database_backups' => PRIVILEGE_ADMIN,
'restore_database' => PRIVILEGE_ADMIN,
'import_database' => PRIVILEGE_ADMIN,
'restore_account' => PRIVILEGE_ADMIN,
'restore_system' => PRIVILEGE_ADMIN,
// Site-owner self-service (site panel). Every one of these derives the
// caller's OWN site from the auth context and NEVER accepts a site
// identifier from a site user: that is the whole security boundary.
'my_site' => PRIVILEGE_SITE,
'my_snapshots' => PRIVILEGE_SITE,
'restore_my_files' => PRIVILEGE_SITE,
'my_databases' => PRIVILEGE_SITE,
'my_database_backups' => PRIVILEGE_SITE,
'restore_my_database' => PRIVILEGE_SITE,
'import_my_database' => PRIVILEGE_SITE,
'restore_my_account' => PRIVILEGE_SITE,
'run_my_backup' => PRIVILEGE_SITE,
];
/**
* Current engine configuration. Secrets are masked.
*
* @return array
*/
public function get_config(): array
{
$cfg = [];
foreach ($this->parseConfig($this->runToolRaw(self::REPO_BIN, ['config-get'])) as $k => $v) {
if (str_contains($k, 'PASSPHRASE') && $v !== '') {
$v = '********';
}
$cfg[$k] = $v;
}
return $cfg;
}
/**
* Set a single configuration key. Only whitelisted keys are accepted (the
* same whitelist borg-apiscp-repo enforces authoritatively; this is a
* matching client-side check so a bad key is rejected before the round
* trip to root).
*
* @param string $key
* @param string $value
* @return bool
*/
public function set_option(string $key, string $value): bool
{
$key = strtoupper(trim($key));
if (!in_array($key, self::ALLOWED_KEYS, true)) {
return error("borg: refusing unknown config key `%s'", $key);
}
if (preg_match('/[\r\n]/', $key . $value)) {
return error('borg: key/value may not contain newlines');
}
return false !== $this->runTool(self::REPO_BIN, ['config-set', $key, $value]);
}
/**
* The one-time repository recovery bundle (instructions + passphrase +
* exported key + repo location), for display and download immediately
* after a repository is created. Contains secrets by design; the GUI
* shows it once and does not surface it again.
*
* @return string|bool the bundle text, or error() on failure
*/
public function key_bundle()
{
return $this->runTool(self::REPO_BIN, ['key-bundle']);
}
/**
* Repository status (borg info), for the "show details" panel.
*
* @return string
*/
public function status()
{
return $this->runTool(self::REPO_BIN, ['status']);
}
/**
* Confirm the configured repository is reachable and the passphrase is
* correct. Always succeeds at the process level; the tool prints "ok" or
* an error message that the caller can display.
*
* @return string|bool
*/
public function check_access()
{
return $this->runTool(self::REPO_BIN, ['check-access']);
}
/**
* Has the repository key/passphrase already been backed up?
*
* @return bool
*/
public function key_backed_up(): bool
{
return $this->runToolRaw(self::REPO_BIN, ['key-status']) === 'yes';
}
/**
* Back up the repository key/passphrase once. Store the result off-machine.
*
* @param string|null $dest
* @param bool $force redo even if already done
* @return string|bool
*/
public function backup_key(?string $dest = null, bool $force = false)
{
$args = ['backup-key'];
if ($dest) {
$args[] = $dest;
}
if ($force) {
$args[] = '--force';
}
return $this->runTool(self::REPO_BIN, $args);
}
/**
* Trigger a backup run now (detached via systemd; returns immediately).
* With no arguments runs the full scheduled backup. A single $site
* (siteN) or a single absolute $path scopes the run; $path takes
* precedence.
*
* @param string $site optional siteN to back up on its own
* @param string $path optional absolute path to back up on its own
* @return bool
*/
public function run(string $site = '', string $path = ''): bool
{
if ($path !== '') {
return false !== $this->runTool(self::REPO_BIN, ['run', '--path', $path]);
}
if ($site !== '') {
if (!preg_match('/^site\d+$/', $site)) {
return error("borg: invalid site identifier `%s'", $site);
}
return false !== $this->runTool(self::REPO_BIN, ['run', '--site', $site]);
}
return false !== $this->runTool(self::REPO_BIN, ['run']);
}
/**
* Is a backup run currently active?
*
* @return bool
*/
public function is_running(): bool
{
return $this->runToolRaw(self::REPO_BIN, ['running']) === 'active';
}
/**
* Sites known to the backup engine, as siteN => domain.
*
* @return array
*/
public function sites(): array
{
$out = [];
foreach (explode("\n", $this->runToolRaw(self::REPO_BIN, ['sites'])) as $line) {
$line = trim($line);
if ($line === '' || !str_contains($line, "\t")) {
continue;
}
[$site, $domain] = explode("\t", $line, 2);
$site = trim($site);
if ($site !== '') {
$out[$site] = trim($domain);
}
}
ksort($out);
return $out;
}
/**
* Archives available for a site's files, newest first, as [{id,time}, ...].
* The archive NAME is used as the id (Borg has no separate manifest id
* the way kopia does). These are shown for information; restore always
* uses the newest (see restore_site()).
*
* @param string $site
* @return array
*/
public function site_snapshots(string $site)
{
if (!preg_match('/^site\d+$/', $site)) {
return error("borg: invalid site identifier `%s'", $site);
}
return $this->archivesByPrefix("$site-shadow");
}
/**
* Restore a site into a staging directory. Borg does not yet support
* point-in-time archive selection here (only the newest siteN-shadow and
* siteN-info archives are restored), and there is no browse subcommand to
* build a subpath picker safely, so this restores the whole site tree.
*
* @param string $site e.g. "site1"
* @param string $target staging directory
* @return string|bool
*/
public function restore_site(string $site, string $target)
{
if (!preg_match('/^site\d+$/', $site)) {
return error("borg: invalid site identifier `%s'", $site);
}
if ($target === '' || $target[0] !== '/') {
return error('borg: target must be an absolute path');
}
return $this->runTool(self::RESTORE_BIN, ['site', $site, $target]);
}
/**
* Initialise (create) the configured Borg repository. Idempotent: does
* nothing if the repository already exists. Requires BORG_PASSPHRASE to
* already be set (see gen_passphrase()).
*
* @return string|bool tool output, or error() on failure
*/
public function init_repo()
{
return $this->runTool(self::REPO_BIN, ['init']);
}
/**
* Generate a strong repository passphrase. The tool stores it as
* BORG_PASSPHRASE in the config AND prints only the passphrase, so the
* GUI can show it once before it disappears behind the mask.
*
* @return string|bool the generated passphrase, or error() on failure
*/
public function gen_passphrase()
{
return $this->runTool(self::REPO_BIN, ['gen-passphrase']);
}
/**
* Choose what the next backup run covers. Sites is a list of siteN ids;
* an empty list means "back up no sites", a selection covering every
* known site collapses to "all". The system flag toggles the
* appliance/system paths.
*
* @param array $sites list of siteN identifiers
* @param bool $system include system paths
* @param array $paths custom absolute paths to include
* @return bool
*/
public function set_backup_selection(array $sites, bool $system, array $paths = []): bool
{
foreach ($sites as $s) {
if (!preg_match('/^site\d+$/', (string)$s)) {
return error("borg: invalid site identifier `%s'", (string)$s);
}
}
$known = array_keys($this->sites());
$sites = array_values(array_unique(array_map('strval', $sites)));
// Empty selection means "back up no sites" (the two-box UI is
// explicit); a selection covering every known site collapses to the
// future-proof "all".
if ($sites === []) {
$value = 'none';
} elseif ($known !== [] && !array_diff($known, $sites)) {
$value = 'all';
} else {
$value = implode(',', $sites);
}
if (false === $this->set_option('BACKUP_SITES', $value)) {
return false;
}
if (false === $this->set_option('BACKUP_SYSTEM', $system ? '1' : '0')) {
return false;
}
// Custom paths: keep only the ones that validate.
$valid = [];
foreach ($paths as $p) {
$p = trim((string)$p);
if ($p !== '' && $this->validate_path($p)) {
$valid[] = $p;
}
}
return false !== $this->set_option('BACKUP_PATHS', implode(',', $valid));
}
/**
* Set the scheduled backup time. $when is "HH:MM" (daily) or a full
* systemd OnCalendar expression. Stored in BACKUP_SCHEDULE and written to
* the timer drop-in.
*
* @param string $when
* @return bool
*/
public function set_schedule(string $when): bool
{
$when = trim($when);
$isTime = (bool)preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $when);
$isCal = (bool)preg_match('#^[0-9A-Za-z:*/, -]+$#', $when);
if ($when === '' || (!$isTime && !$isCal)) {
return error('borg: invalid schedule (use HH:MM or a systemd OnCalendar expression)');
}
if (false === $this->set_option('BACKUP_SCHEDULE', $when)) {
return false;
}
return false !== $this->runTool(self::REPO_BIN, ['set-schedule', $when]);
}
/**
* Run full repository maintenance (borg compact), detached.
*
* @return bool
*/
public function maintenance(): bool
{
return false !== $this->runTool(self::REPO_BIN, ['maintenance']);
}
/**
* Verify repository and archive consistency (borg check), detached.
* $data also reads file contents back (slow, thorough); without it only
* metadata is checked.
*
* @param bool $data
* @return bool
*/
public function verify(bool $data = false): bool
{
$args = ['verify'];
if ($data) {
$args[] = '--data';
}
return false !== $this->runTool(self::REPO_BIN, $args);
}
/**
* Send a test notification email to the configured NOTIFY_EMAIL, to
* confirm mail delivery works.
*
* @return string|bool tool output, or error() on failure
*/
public function notify_test()
{
return $this->runTool(self::REPO_BIN, ['notify-test']);
}
/**
* Apply retention (borg prune) across every archive prefix right now,
* per the configured RETENTION_KEEP_* settings. There is no
* "apply-policy" step here as with kopia's global policy: retention
* lives in config and prune is what enforces it, both automatically
* after each scheduled run and on demand via this verb.
*
* @return bool
*/
public function prune(): bool
{
return false !== $this->runTool(self::REPO_BIN, ['prune']);
}
/**
* Repository connection summary for the UI (prepopulate + lock). There
* is no backend "type" in the Borg model, only a repository location.
*
* @return array ['connected'=>bool, 'location'=>string]
*/
public function repo_info(): array
{
$cfg = $this->get_config();
$connected = ($this->runToolRaw(self::REPO_BIN, ['check-access']) === 'ok');
return ['connected' => $connected, 'location' => $cfg['BORG_REPO'] ?? ''];
}
/**
* Is $path a plausible absolute path? borg-apiscp-repo has no
* validate-path subcommand (unlike the kopia engine's repo tool), so
* this is a best-effort structural check performed here in PHP rather
* than a root round trip; it is not authoritative. A path that does not
* actually exist when the engine runs as root is simply skipped with a
* warning in the log, so this only guards against obviously malformed
* input in the GUI.
*
* @param string $path
* @return bool
*/
public function validate_path(string $path): bool
{
return $path !== '' && $path[0] === '/' && !preg_match('/[\r\n]/', $path);
}
/**
* Site ids that have at least one file archive (for greying out empty
* sites in the restore picker). Queried one site at a time via
* list-json, since Borg's list-json takes a single PREFIX-* glob rather
* than kopia's arbitrary tag filters.
*
* @return array list of siteN
*/
public function sites_with_snapshots(): array
{
$out = [];
foreach (array_keys($this->sites()) as $site) {
if ($this->archivesByPrefix("$site-shadow") !== []) {
$out[] = $site;
}
}
return $out;
}
/**
* Databases captured for a site, as [{engine,db}, ...]. The repo tool
* prints tab-separated "<engine>\t<db>" lines (engine is mysql or pgsql),
* read live from ApisCP (not from an archive). Returns [] on failure so
* the UI can degrade gracefully.
*
* @param string $site e.g. "site1"
* @return array
*/
public function site_databases(string $site): array
{
if (!preg_match('/^site\d+$/', $site)) {
return error("borg: invalid site identifier `%s'", $site);
}
$out = [];
foreach (explode("\n", $this->runToolRaw(self::REPO_BIN, ['list-databases', $site])) as $line) {
$line = trim($line);
if ($line === '' || !str_contains($line, "\t")) {
continue;
}
[$engine, $db] = explode("\t", $line, 2);
$engine = trim($engine);
$db = trim($db);
if ($engine !== '' && $db !== '') {
$out[] = ['engine' => $engine, 'db' => $db];
}
}
return $out;
}
/**
* Dated database archives for a site, newest first, as [{id,time}, ...].
* Each run captures all of the site's databases into one siteN-db
* archive, so these are the points in time a database can be restored
* from (informational: restore always uses the newest).
*
* @param string $site e.g. "site1"
* @return array
*/
public function database_backups(string $site): array
{
if (!preg_match('/^site\d+$/', $site)) {
return error("borg: invalid site identifier `%s'", $site);
}
return $this->archivesByPrefix("$site-db");
}
/**
* Restore a database dump to a staging $target (non-destructive). Prints
* the restored dump path. Does not touch the live database. Always uses
* the newest siteN-db archive.
*
* @param string $site e.g. "site1"
* @param string $engine "mysql" or "pgsql"
* @param string $db database name
* @param string $target absolute staging path
* @return string|bool the restored dump path, or error() on failure
*/
public function restore_database(string $site, string $engine, string $db, string $target)
{
if (!preg_match('/^site\d+$/', $site)) {
return error("borg: invalid site identifier `%s'", $site);
}
if (!in_array($engine, ['mysql', 'pgsql'], true)) {
return error("borg: invalid database engine `%s'", $engine);
}
if (!preg_match('/^[A-Za-z0-9_-]+$/', $db)) {
return error("borg: invalid database name `%s'", $db);
}
if ($target === '' || $target[0] !== '/') {
return error('borg: target must be an absolute path');
}
return $this->runTool(self::RESTORE_BIN, ['restore-db', $site, $engine, $db, $target]);
}
/**
* Restore a WHOLE account into a staging $target: files plus all
* databases (newest siteN-db archive, under $target/databases).
* Non-destructive; review then copy/import.
*
* @param string $site e.g. "site1"
* @param string $target absolute staging directory
* @return string|bool tool output, or error() on failure
*/
public function restore_account(string $site, string $target)
{
if (!preg_match('/^site\d+$/', $site)) {
return error("borg: invalid site identifier `%s'", $site);
}
if ($target === '' || $target[0] !== '/') {
return error('borg: target must be an absolute path');
}
return $this->runTool(self::RESTORE_BIN, ['account', $site, $target]);
}
/**
* Import the newest dump into the live database. DESTRUCTIVE: this
* overwrites the running database via a native import. The GUI confirms
* before calling. Unlike the kopia engine, the caller supplies no
* dumpfile: borg-apiscp-restore's import-db always restores and imports
* the newest siteN-db archive itself.
*
* @param string $site e.g. "site1"
* @param string $engine "mysql" or "pgsql"
* @param string $db database name
* @return string|bool tool output, or error() on failure
*/
public function import_database(string $site, string $engine, string $db)
{
if (!preg_match('/^site\d+$/', $site)) {
return error("borg: invalid site identifier `%s'", $site);
}
if (!in_array($engine, ['mysql', 'pgsql'], true)) {
return error("borg: invalid database engine `%s'", $engine);
}
if (!preg_match('/^[A-Za-z0-9_-]+$/', $db)) {
return error("borg: invalid database name `%s'", $db);
}
return $this->runTool(self::RESTORE_BIN, ['import-db', $site, $engine, $db]);
}
/**
* Restore the newest _system archive into a staging $target.
*
* @param string $target absolute staging directory
* @return string|bool tool output, or error() on failure
*/
public function restore_system(string $target)
{
if ($target === '' || $target[0] !== '/') {
return error('borg: target must be an absolute path');
}
return $this->runTool(self::RESTORE_BIN, ['system', $target]);
}
/* ---- site-owner self-service (PRIVILEGE_SITE) ------------------- */
/*
* These verbs power the site-owner edition (GUI app apps/myborgbackups
* in the site panel). Each one resolves the caller's OWN site from the
* ApisCP auth context via authSite() and passes only that site id to
* the Layer 1 binaries. A site user cannot name another site, cannot
* configure the repository, cannot touch system paths, and cannot see
* any other account: the admin verbs above are PRIVILEGE_ADMIN, and
* these derive their scope from the authenticated session, never from a
* param.
*/
/**
* The caller's own site and primary domain (from the auth context).
*
* @return array ['site' => siteN|'', 'domain' => string]
*/
public function my_site(): array
{
$site = $this->authSite();
return ['site' => $site, 'domain' => $site === '' ? '' : $this->authDomain()];
}
/**
* Archives available for the caller's own site, newest first, as
* [{id,time}, ...]. Informational only; restore always uses the newest.
*
* @return array
*/
public function my_snapshots(): array
{
$site = $this->authSite();
if ($site === '') {
return [];
}
return $this->archivesByPrefix("$site-shadow");
}
/**
* Restore the caller's OWN files into their OWN site filesystem, chowned
* to them, and return the in-site path (relative to their site root)
* they can browse. Always restores the newest archive: there is no
* point-in-time selection or subpath picker in this release (see
* restore_site()).
*
* @return string|bool the in-site path, or error()
*/
public function restore_my_files()
{
$site = $this->authSite();
if ($site === '') {
return error('borg: no site context');
}
return $this->runTool(self::RESTORE_BIN, ['owner-files', $site]);
}
/**
* Databases captured for the caller's own site, as [{engine,db}, ...].
*
* @return array
*/
public function my_databases(): array
{
$site = $this->authSite();
if ($site === '') {
return [];
}
$r = $this->site_databases($site);
return is_array($r) ? $r : [];
}
/**
* Dated database archives for the caller's own site, newest first.
*
* @return array [{id,time}, ...]
*/
public function my_database_backups(): array
{
$site = $this->authSite();
if ($site === '') {
return [];
}
$r = $this->database_backups($site);
return is_array($r) ? $r : [];
}
/**
* Restore a dump of one of the caller's OWN databases into their OWN
* site filesystem (non-destructive), chowned to them. Returns the
* in-site path. Always uses the newest archive.
*
* @param string $engine "mysql" or "pgsql"
* @param string $db database name (must belong to this site)
* @return string|bool the in-site path, or error()
*/
public function restore_my_database(string $engine, string $db)
{
$site = $this->authSite();
if ($site === '') {
return error('borg: no site context');
}
if (!in_array($engine, ['mysql', 'pgsql'], true)) {
return error("borg: invalid database engine `%s'", $engine);
}
if (!preg_match('/^[A-Za-z0-9_-]+$/', $db)) {
return error("borg: invalid database name `%s'", $db);
}
if (!$this->ownsDatabase($site, $engine, $db)) {
return error('borg: database not found for your site');
}
return $this->runTool(self::RESTORE_BIN, ['owner-restore-db', $site, $engine, $db]);
}
/**
* Import the newest backed-up dump of one of the caller's OWN databases
* back into their live database. DESTRUCTIVE: requires $confirm. The
* owner supplies no file; only what was backed up for their own site is
* used.
*
* @param string $engine "mysql" or "pgsql"
* @param string $db database name (must belong to this site)
* @param bool $confirm must be true to proceed
* @return string|bool tool output, or error()
*/
public function import_my_database(string $engine, string $db, bool $confirm = false)
{
$site = $this->authSite();
if ($site === '') {
return error('borg: no site context');
}
if (!$confirm) {
return error('borg: confirm required to import over your live database');
}
if (!in_array($engine, ['mysql', 'pgsql'], true)) {
return error("borg: invalid database engine `%s'", $engine);
}
if (!preg_match('/^[A-Za-z0-9_-]+$/', $db)) {
return error("borg: invalid database name `%s'", $db);
}
if (!$this->ownsDatabase($site, $engine, $db)) {
return error('borg: database not found for your site');
}
return $this->runTool(self::RESTORE_BIN, ['owner-import-db', $site, $engine, $db]);
}
/**
* Restore the caller's OWN whole account (files + all databases) into
* their own file space, chowned to them. Returns the in-site path.
*
* @return string|bool the in-site path, or error()
*/
public function restore_my_account()
{
$site = $this->authSite();
if ($site === '') {
return error('borg: no site context');
}
return $this->runTool(self::RESTORE_BIN, ['owner-account', $site]);
}
/**
* Trigger a backup run scoped to the caller's own site (detached).
*
* @return bool
*/
public function run_my_backup(): bool
{
$site = $this->authSite();
if ($site === '') {
return error('borg: no site context');
}
return false !== $this->runTool(self::REPO_BIN, ['run', '--site', $site]);
}
/* ---------------------------------------------------------------- */
/**
* The caller's own site id ("siteN") from the ApisCP auth context, or ''
* if it cannot be resolved. Auth_Info_User::$site is set to
* 'site'.$site_id by the framework (lib/Auth/Info/User.php), so this is
* the authenticated session's site, never a caller-supplied value.
*/
private function authSite(): string
{
$site = '';
try {
$ctx = $this->getAuthContext();
if ($ctx) {
$site = (string)($ctx->site ?? '');
}
} catch (\Throwable $e) {
$site = '';
}
return preg_match('/^site\d+$/', $site) ? $site : '';
}
/** The caller's own primary domain from the auth context, or ''. */
private function authDomain(): string
{
try {
$ctx = $this->getAuthContext();
return $ctx ? (string)($ctx->domain ?? '') : '';
} catch (\Throwable $e) {
return '';
}
}
/** True if $site currently has a database $db on $engine. */
private function ownsDatabase(string $site, string $engine, string $db): bool
{
$r = $this->site_databases($site);
if (!is_array($r)) {
return false;
}
foreach ($r as $row) {
if (($row['engine'] ?? '') === $engine && ($row['db'] ?? '') === $db) {
return true;
}
}
return false;
}
/** Raw (unmasked) config keys, fetched from the root-owned repo tool. */
private function rawConfig(): array
{
return $this->parseConfig($this->runToolRaw(self::REPO_BIN, ['config-get']));
}
/** Parse KEY=VALUE lines (as emitted by `borg-apiscp-repo config-get`). */
private function parseConfig(string $raw): array
{
$cfg = [];
foreach (explode("\n", $raw) as $line) {
if ($line === '' || $line[0] === '#' || !str_contains($line, '=')) {
continue;
}
[$k, $v] = explode('=', $line, 2);
$cfg[trim($k)] = trim($v);
}
return $cfg;
}
/**
* Archives matching PREFIX-*, newest first, as [{id,time,epoch}, ...].
* The archive NAME is the id (Borg has no separate manifest id). Reads
* `borg-apiscp-restore list-json PREFIX`, which prints Borg JSON of the
* form {"archives":[{"archive":"siteN-shadow-...","time":"ISO", ...}]}.
*
* @param string $prefix e.g. "site1-shadow"
* @return array
*/
private function archivesByPrefix(string $prefix): array
{
$raw = $this->runToolRaw(self::RESTORE_BIN, ['list-json', $prefix]);
$data = json_decode($raw, true);
$archives = (is_array($data) && !empty($data['archives']) && is_array($data['archives']))
? $data['archives']
: [];
$out = [];
foreach ($archives as $a) {
if (empty($a['archive'])) {
continue;
}
$time = $a['time'] ?? ($a['start'] ?? '');
$out[] = ['id' => $a['archive'], 'time' => $time, 'epoch' => $time ? (int)strtotime((string)$time) : 0];
}
usort($out, static fn($a, $b) => $b['epoch'] <=> $a['epoch']);
return $out;
}
/**
* Run one of the Layer 1 binaries as root with a fixed, non-shell
* argument list. The module runs in the ApisCP frontend as the
* unprivileged `apnscp` user, which cannot read root's Borg config or
* list /home/virtual, so every tool is invoked via `sudo -n` (a scoped
* sudoers entry permits exactly these three binaries without a
* password). Returns trimmed stdout, or error() on failure.
*/
private function runTool(string $bin, array $args)
{
if (!is_executable($bin)) {
return error("borg: %s not installed", $bin);
}
$ret = \Util_Process::exec($this->sudoFmt($bin, $args), ...$args);
if (!$ret['success']) {
return error('borg: %s failed: %s', basename($bin), trim(($ret['stderr'] ?? '') ?: ($ret['output'] ?? '')));
}
return trim($ret['output'] ?? '');
}
/**
* As runTool() but returns raw trimmed stdout (or '' on any failure)
* without routing through error(). Used where the caller parses the
* output itself (JSON, KEY=VALUE, tab-separated) and an error banner
* would be noise.
*/
private function runToolRaw(string $bin, array $args): string
{
if (!is_executable($bin)) {
return '';
}
$ret = \Util_Process::exec($this->sudoFmt($bin, $args), ...$args);
if (empty($ret['success'])) {
return '';
}
return trim($ret['output'] ?? '');
}
/**
* Build a positional `sudo -n <bin> %s %s ...` format string so
* Util_Process printf-escapes every argument. The binary path is a fixed
* constant, escaped here; argument values are passed raw to exec().
*/
private function sudoFmt(string $bin, array $args): string
{
$fmt = 'sudo -n ' . escapeshellarg($bin);
foreach ($args as $_) {
$fmt .= ' %s';
}
return $fmt;
}
}

18
src/templates/admin.php Normal file
View file

@ -0,0 +1,18 @@
<?php
/**
* apiscp-borg menu registration (appliance-admin panel).
*
* Installed upgrade-safe at config/custom/templates/admin.php, which
* lib/Template/Engine.php::load_configuration() includes with $templateClass
* in scope after the core admin menu. Adds the "Borg Backups" link.
*
* If you already maintain a custom admin.php, copy just the create_link()
* call below into it instead of overwriting your file.
*/
$templateClass->create_link(
'Borg Backups', // label
'/apps/borg', // href -> config/custom/apps/borg
true, // assertion: always visible within the admin menu
null, // icon
'services' // category: "System" (internal id 'services')
);

20
src/templates/site.php Normal file
View file

@ -0,0 +1,20 @@
<?php
/**
* apiscp-borg menu registration (site-owner panel).
*
* Installed upgrade-safe at config/custom/templates/site.php, which
* lib/Template/Engine.php::load_configuration() includes with $templateClass
* in scope after the core site menu. Adds a "My Borg Backups" link that
* opens the site-owner GUI app (config/custom/apps/myborgbackups), scoped
* to the signed-in account's own site only.
*
* If you already maintain a custom site.php, copy just the create_link()
* call below into it instead of overwriting your file.
*/
$templateClass->create_link(
'My Borg Backups', // label
'/apps/myborgbackups', // href -> config/custom/apps/myborgbackups
true, // assertion: visible to the site administrator
null, // icon
'account' // category (site panel "Account" section)
);

114
uninstall.sh Normal file
View file

@ -0,0 +1,114 @@
#!/bin/sh
#
# uninstall.sh: remove apiscp-borg (Layer 1 engine + Layer 2 panel integration).
#
# What it ALWAYS removes:
# - the systemd timer/service (disabled + stopped first) and any schedule drop-in
# - the Layer 1 binaries and shared library
# - the Layer 2 module, both GUI apps, our menu links, account hooks, and the
# scoped sudoers file
# - the Prometheus textfile metric
#
# What it KEEPS by default (so an uninstall never destroys your ability to
# recover): the Borg repository and all its archives (never touched), the borg
# binary itself, /etc/apiscp-borg/config (holds the repository passphrase),
# the staging dir, and the key backup dir.
#
# Pass --purge to ALSO delete /etc/apiscp-borg (config + passphrase!), the
# staging dir (/var/lib/apiscp-borg), and the key backup dir. The Borg
# repository and its archives are STILL not touched; remove those yourself with
# borg if you really want the backups gone.
#
# Run as root on the ApisCP host. Idempotent.
set -u
PURGE=0
for arg in "$@"; do
case "$arg" in
--purge) PURGE=1 ;;
-h|--help) sed -n '2,22p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown option: $arg" >&2; exit 2 ;;
esac
done
[ "$(id -u)" = 0 ] || { echo "run as root" >&2; exit 1; }
LIBDIR=/usr/local/lib/apiscp-borg
BINDIR=/usr/local/bin
UNITDIR=/etc/systemd/system
CFGDIR=/etc/apiscp-borg
STAGE=/var/lib/apiscp-borg
CP_ROOT="${CP_ROOT:-/usr/local/apnscp}"
echo "== Layer 2: ApisCP panel integration =="
# Module.
rm -f "$CP_ROOT/lib/modules/surrogates/borg.php" && echo "removed module surrogate" || true
# GUI apps (both editions).
rm -rf "$CP_ROOT/config/custom/apps/borg" "$CP_ROOT/config/custom/apps/myborgbackups" && echo "removed GUI apps" || true
# Menu links: strip our create_link lines (and their marker comment); drop the
# file only if it becomes effectively empty.
for _tpl in admin site; do
_f="$CP_ROOT/config/custom/templates/$_tpl.php"
[ -f "$_f" ] || continue
if grep -q "apps/borg\|apps/myborgbackups\|apiscp-borg" "$_f"; then
_tmp=$(mktemp)
# Drop our marker comments and any create_link referencing our apps.
grep -v "apiscp-borg" "$_f" | grep -v "create_link(.*apps/borg" | grep -v "create_link(.*apps/myborgbackups" > "$_tmp"
# If nothing but "<?php" and whitespace remains, remove the file entirely.
if [ -z "$(grep -vE '^\s*(<\?php)?\s*$' "$_tmp")" ]; then
rm -f "$_f"; echo "removed $_tpl.php menu (was ours only)"
else
install -m 0644 "$_tmp" "$_f"; echo "stripped our link from $_tpl.php"
fi
rm -f "$_tmp"
fi
done
# Account hooks (only the ones that are ours).
for _hook in addDomain suspendDomain deleteDomain; do
_h="$CP_ROOT/config/custom/hooks/$_hook.sh"
[ -f "$_h" ] && grep -q apiscp-borg "$_h" 2>/dev/null && { rm -f "$_h"; echo "removed hook $_hook.sh"; }
done
# Sudoers.
rm -f /etc/sudoers.d/apiscp-borg && echo "removed sudoers drop-in" || true
# Restart the panel so it forgets the app/menu.
systemctl restart apnscp 2>/dev/null || echo " restart apnscp yourself to drop the menu entry" >&2
echo "== Layer 1: engine =="
# Stop + disable + remove the timer/service and any schedule drop-in.
systemctl disable --now borg-apiscp-backup.timer 2>/dev/null || true
systemctl stop borg-apiscp-backup.service 2>/dev/null || true
rm -f "$UNITDIR/borg-apiscp-backup.timer" "$UNITDIR/borg-apiscp-backup.service"
rm -rf "$UNITDIR/borg-apiscp-backup.timer.d"
systemctl daemon-reload 2>/dev/null || true
echo "removed systemd units"
# Binaries + library.
rm -f "$BINDIR/borg-apiscp-backup" "$BINDIR/borg-apiscp-restore" "$BINDIR/borg-apiscp-repo"
rm -rf "$LIBDIR"
echo "removed binaries and library"
# Prometheus metric, if any.
for d in /var/lib/node_exporter/textfile_collector /var/lib/prometheus/node-exporter /var/lib/prometheus/node_exporter; do
rm -f "$d/apiscp-borg.prom" 2>/dev/null || true
done
if [ "$PURGE" = 1 ]; then
echo "== purge: removing config, staging, and key backups =="
# Read KEY_BACKUP_DIR from the config before deleting it.
_keydir=/root/apiscp-borg-keys
[ -r "$CFGDIR/config" ] && _keydir=$(sed -n "s/^[[:space:]]*KEY_BACKUP_DIR[[:space:]]*=[[:space:]]*['\"]\{0,1\}\([^'\"]*\)['\"]\{0,1\}.*/\1/p" "$CFGDIR/config" | head -1)
[ -n "$_keydir" ] || _keydir=/root/apiscp-borg-keys
rm -rf "$CFGDIR" "$STAGE"
echo "removed $CFGDIR and $STAGE"
echo "NOTE: the key backup dir ($_keydir) is your ONLY offline copy of the repository passphrase/key."
echo " Remove it yourself if you are certain: rm -rf '$_keydir'"
else
echo "kept $CFGDIR (repository passphrase), $STAGE, and the key backup dir."
echo "Re-run with --purge to remove them too."
fi
cat <<'EOF'
Uninstalled. The Borg repository and all its archives were NOT touched: your
backups are intact and can still be restored with plain borg, or by
reinstalling apiscp-borg. borg itself was left installed.
EOF