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:
parent
3ce5bf1a47
commit
02403b72c0
14 changed files with 2681 additions and 0 deletions
148
install-layer2.sh
Normal file
148
install-layer2.sh
Normal 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
|
||||||
13
src/apps/borg/application.yml
Normal file
13
src/apps/borg/application.yml
Normal 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:<verb></code>.
|
||||||
480
src/apps/borg/borg.php
Normal file
480
src/apps/borg/borg.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
550
src/apps/borg/views/index.blade.php
Normal file
550
src/apps/borg/views/index.blade.php
Normal 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:<verb></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">→</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" id="borg-remove">←</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), …@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), …@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>
|
||||||
14
src/apps/myborgbackups/application.yml
Normal file
14
src/apps/myborgbackups/application.yml
Normal 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.
|
||||||
162
src/apps/myborgbackups/myborgbackups.php
Normal file
162
src/apps/myborgbackups/myborgbackups.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
160
src/apps/myborgbackups/views/index.blade.php
Normal file
160
src/apps/myborgbackups/views/index.blade.php
Normal 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), …@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-<date>/</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), …@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
21
src/hooks/addDomain.sh
Normal 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
21
src/hooks/deleteDomain.sh
Normal 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
|
||||||
21
src/hooks/suspendDomain.sh
Normal file
21
src/hooks/suspendDomain.sh
Normal 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
939
src/modules/borg.php
Normal 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
18
src/templates/admin.php
Normal 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
20
src/templates/site.php
Normal 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
114
uninstall.sh
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue