diff --git a/install-layer2.sh b/install-layer2.sh new file mode 100644 index 0000000..0dacdc0 --- /dev/null +++ b/install-layer2.sh @@ -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/.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" </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 diff --git a/src/apps/borg/application.yml b/src/apps/borg/application.yml new file mode 100644 index 0000000..6625d61 --- /dev/null +++ b/src/apps/borg/application.yml @@ -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 + cpcmd borg:<verb>. diff --git a/src/apps/borg/borg.php b/src/apps/borg/borg.php new file mode 100644 index 0000000..c624ab1 --- /dev/null +++ b/src/apps/borg/borg.php @@ -0,0 +1,480 @@ +borg_(), 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); + } + } diff --git a/src/apps/borg/views/index.blade.php b/src/apps/borg/views/index.blade.php new file mode 100644 index 0000000..015f57f --- /dev/null +++ b/src/apps/borg/views/index.blade.php @@ -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 + + + +
+ + + + @if ($Page->justCreated()) + {{-- One-time recovery key panel, shown ONLY immediately after creation. --}} +
+

Save your repository recovery key now

+

This is shown only once. If this passphrase/key is lost, your encrypted + backups are permanently irrecoverable, no matter how many copies of the + data exist. Download it and store it somewhere safe and off this server.

+ + +

Reload the page once you have saved it; it will not be shown again.

+
+ @endif + +

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 cpcmd borg:<verb>.

+ + + {{-- ============================ REPOSITORY ============================ --}} +

Repository

+

+ @if ($info['connected']) + connected + @if($info['location']) at {{ $info['location'] }}@endif + @else + no repository configured + @endif + +

+
{{ $Page->repoStatus() }}
+ + @if ($locked) +
+ +
+ @endif + +
+
+ Repository +

There is only one repository type: a local path or an ssh:// 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.

+
+ +
+
+
+ +
+
+
+
+
+
+ +
+ +
+
+
+ +
+
+ +
+
+ + +
+
+ +
+
+

Borg encrypts the repository with a passphrase. The recovery key is shown once, immediately after creation.

+ + +
+
+
+
+ + {{-- ========================= WHAT TO BACK UP ========================= --}} +

What to back up

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+ +
+
+
+
+ +
+ +
+ + {{-- ====================== RETENTION AND EXCLUDES ===================== --}} +

Retention and excludes

+

Retention is applied by borg prune, run automatically after every backup and on demand below. Leave a box blank for "no limit" on that bucket.

+
+
+ Keep how many archives +
+
+ + +
+ @foreach (['RETENTION_KEEP_DAILY' => 'Daily', 'RETENTION_KEEP_WEEKLY' => 'Weekly', 'RETENTION_KEEP_MONTHLY' => 'Monthly', 'RETENTION_KEEP_ANNUAL' => 'Annual'] as $key => $label) +
+ + +
+ @endforeach +
+

"Within" is a duration such as 7d, 4w, 6m or 1y (H, d, w, m, y). The others are archive counts.

+
+
+ Exclude patterns +

One glob per line (for example *.log, cache/, node_modules/). The restore staging area /.borg-restore is always excluded.

+ +
+ + +
+ + {{-- ============================== RUN =============================== --}} +

Run a backup

+

+ @if ($Page->isRunning()) + a backup is currently running + @else + idle + @endif +

+
+ + + + +
+ + {{-- ==================== SCHEDULE AND MAINTENANCE ===================== --}} +

Schedule and maintenance

+
+ + + + Or set BACKUP_SCHEDULE to a systemd OnCalendar expression on the CLI. +
+

Maintenance compacts the repository (borg compact); verification (borg check) confirms repository and archive consistency. Both run in the background.

+
+ + + +
+ + {{-- ============================ NOTIFICATIONS ======================== --}} +

Notifications

+

Email a report of each backup run. Requires a working mail system on this server.

+
+ + + + + + +
+ + {{-- ======================= REPOSITORY KEY BACKUP ===================== --}} +

Repository key backup

+

+ 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()) + key backed up + @else + not backed up yet + @endif +

+
+ + + +
+ + {{-- ============================= RESTORE ============================= --}} +

Restore

+ @php $restoreKind = $Page->restoreMode(); @endphp +

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.

+
+ + +
+ +
+

Site files

+ @if (!count($sites)) +

No sites found.

+ @else +
+ + +
+ + @if ($Page->selectedSite() !== '') + @php $snaps = $Page->selectedSnaps(); @endphp +

{{ $Page->selectedSite() }}

+ @if (!count($snaps)) +

no archives This site has no backups yet.

+ @else +

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.

+
+ +
+ +
+
+
+
+ +
+
+
+ @endif + @endif + @endif +
{{-- /files panel --}} + +
+

Entire account

+

Restore a whole account in one step: its newest files plus all of its databases (newest backup) into a staging directory. Databases land under databases/; review and import them as needed.

+ @if (!count($sites)) +

No sites found.

+ @else +
+ + + +
+ @endif +
{{-- /account panel --}} + +
+

System information

+

Restores the newest backed-up system archive into a staging directory (non-destructive).

+
+ + + +
+
{{-- /system panel --}} + +
+

Databases

+ @if (!count($sites)) +

No sites found.

+ @else +
+ + +
+ + @php $dbs = $Page->dbList(); @endphp + @if ($Page->dbSite() !== '') +

{{ $Page->dbSite() }}

+ @php $dbBackups = $Page->dbBackups(); @endphp +

+ @if (count($dbBackups)) + 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. + @else + No dated database backups captured yet for this site. + @endif +

+ @if (!count($dbs)) +

no databases No databases found for this site.

+ @else +

Warning: importing overwrites the LIVE database and is destructive. Restoring a dump to staging is safe.

+ + + + + + @foreach ($dbs as $row) + @php $engine = $row['engine'] ?? ''; $db = $row['db'] ?? ''; $staging = '/var/tmp/restore-db-' . $db; @endphp + + + + + + + @endforeach + +
EngineDatabaseRestore dump to stagingImport into live database
{{ $engine }}{{ $db }} +
+ + + + + +
+
+
+ + + + + +
+
+ @endif + @endif + @endif +
{{-- /database panel --}} + +
+ + diff --git a/src/apps/myborgbackups/application.yml b/src/apps/myborgbackups/application.yml new file mode 100644 index 0000000..3f6c805 --- /dev/null +++ b/src/apps/myborgbackups/application.yml @@ -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 + /.borg-restore/) 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. diff --git a/src/apps/myborgbackups/myborgbackups.php b/src/apps/myborgbackups/myborgbackups.php new file mode 100644 index 0000000..35a4d26 --- /dev/null +++ b/src/apps/myborgbackups/myborgbackups.php @@ -0,0 +1,162 @@ +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(); + } + } diff --git a/src/apps/myborgbackups/views/index.blade.php b/src/apps/myborgbackups/views/index.blade.php new file mode 100644 index 0000000..ce3de0a --- /dev/null +++ b/src/apps/myborgbackups/views/index.blade.php @@ -0,0 +1,160 @@ +@php + $me = $Page->mySite(); + $site = $me['site'] ?? ''; + $domain = $me['domain'] ?? ''; + $snaps = $Page->snaps(); + $dbs = $Page->dbs(); + $result = $Page->result(); +@endphp + + + +
+ + + + @if ($site === '') +
+ Your account's backup context could not be determined. Please contact + your administrator. +
+ @else + +

+ Backups for {{ $domain ?: $site }}. + Restores are placed into your own files under + /.borg-restore/; retrieve them with the File Manager or SFTP. +

+

Every restore uses the newest backup: there is no point-in-time selection or file picker in this release.

+ + @if ($result !== '') +
+ Last restore is available in your files at {{ $result }}. +
+ @endif + + {{-- ============================== RESTORE FILES ============================== --}} +

Restore files

+
+ +
+ + @if (count($snaps)) +

Backups taken (newest first): {{ implode(', ', array_map(fn($s) => $Page->fmtTime($s['time']), array_slice($snaps, 0, 6))) }}@if(count($snaps) > 6), …@endif.

+
+ +
+ @else +

Click "Show my backups" to list the backups taken for your site.

+ @endif + + {{-- ============================== RESTORE EVERYTHING ============================== --}} +

Restore my entire account

+

Restore everything in one step: all your files (with permissions) plus all your databases (newest backup) into your own file space under /.borg-restore/account-<date>/. Databases land under databases/ for you to review.

+
+ +
+ + {{-- ============================== DATABASES ============================== --}} +

Databases

+
+ +
+ + @if (count($dbs)) + @php $dbBackups = $Page->dbBackups(); @endphp + @if (count($dbBackups)) +

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.

+ @endif +

Warning: importing overwrites your LIVE database and cannot be undone. Restoring a dump to your files is safe.

+ + + + + + @foreach ($dbs as $row) + @php $engine = $row['engine'] ?? ''; $db = $row['db'] ?? ''; @endphp + + + + + + + @endforeach + +
EngineDatabaseRestore dump to my filesImport into live database
{{ $engine }}{{ $db }} +
+ + + +
+
+
+ + + + +
+
+ @else +

Click "List my databases" to see the databases captured for your site.

+ @endif + + {{-- ============================== RUN A BACKUP ============================== --}} +

Back up now

+

Run a backup of your site on demand (in addition to the scheduled backups).

+
+ +
+ + @endif + +
+ + diff --git a/src/hooks/addDomain.sh b/src/hooks/addDomain.sh new file mode 100644 index 0000000..d3907c5 --- /dev/null +++ b/src/hooks/addDomain.sh @@ -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 diff --git a/src/hooks/deleteDomain.sh b/src/hooks/deleteDomain.sh new file mode 100644 index 0000000..b24239c --- /dev/null +++ b/src/hooks/deleteDomain.sh @@ -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 diff --git a/src/hooks/suspendDomain.sh b/src/hooks/suspendDomain.sh new file mode 100644 index 0000000..f60c592 --- /dev/null +++ b/src/hooks/suspendDomain.sh @@ -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 diff --git a/src/modules/borg.php b/src/modules/borg.php new file mode 100644 index 0000000..643b706 --- /dev/null +++ b/src/modules/borg.php @@ -0,0 +1,939 @@ +` 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 "\t" 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 %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; + } + } diff --git a/src/templates/admin.php b/src/templates/admin.php new file mode 100644 index 0000000..abf12db --- /dev/null +++ b/src/templates/admin.php @@ -0,0 +1,18 @@ +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') + ); diff --git a/src/templates/site.php b/src/templates/site.php new file mode 100644 index 0000000..199f3ea --- /dev/null +++ b/src/templates/site.php @@ -0,0 +1,20 @@ +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) + ); diff --git a/uninstall.sh b/uninstall.sh new file mode 100644 index 0000000..d96d8ba --- /dev/null +++ b/uninstall.sh @@ -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 "/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