Merge pull request #58 from naturallaw777/copilot/add-manual-backup-button

[WIP] Add manual backup button in Hub sidebar for Sovran_SystemsOS
This commit is contained in:
Sovran_Systems
2026-04-04 22:24:23 -05:00
committed by GitHub
5 changed files with 539 additions and 5 deletions

View File

@@ -0,0 +1,173 @@
#!/usr/bin/env bash
# ── Sovran Hub Backup Script ─────────────────────────────────────
# Backs up Sovran_SystemsOS data to an external USB hard drive.
# Designed for the Hub web UI (no GUI dependencies).
#
# Usage:
# BACKUP_TARGET=/run/media/<user>/<drive> bash sovran-hub-backup.sh
# (or run with no env var to auto-detect the first external drive)
set -euo pipefail
BACKUP_LOG="/var/log/sovran-hub-backup.log"
BACKUP_STATUS="/var/log/sovran-hub-backup.status"
MEDIA_ROOT="/run/media"
MIN_FREE_GB=10
# ── Logging helpers ──────────────────────────────────────────────
log() {
local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
echo "$msg" | tee -a "$BACKUP_LOG"
}
set_status() {
echo "$1" > "$BACKUP_STATUS"
}
fail() {
log "ERROR: $*"
set_status "FAILED"
exit 1
}
# ── Initialise log file ──────────────────────────────────────────
: > "$BACKUP_LOG"
set_status "RUNNING"
log "=== Sovran_SystemsOS Hub Backup ==="
log "Starting backup process…"
# ── Detect target drive ──────────────────────────────────────────
if [[ -n "${BACKUP_TARGET:-}" ]]; then
TARGET="$BACKUP_TARGET"
log "Using specified backup target: $TARGET"
else
log "Auto-detecting external drives under $MEDIA_ROOT"
TARGET=""
if [[ -d "$MEDIA_ROOT" ]]; then
# Walk /run/media/<user>/<drive>
while IFS= read -r -d '' mnt; do
if mountpoint -q "$mnt" 2>/dev/null; then
TARGET="$mnt"
break
fi
done < <(find "$MEDIA_ROOT" -mindepth 2 -maxdepth 2 -type d -print0 2>/dev/null)
fi
if [[ -z "$TARGET" ]]; then
fail "No external drive detected under $MEDIA_ROOT. " \
"Please plug in an exFAT-formatted USB drive (≥500 GB) and try again."
fi
log "Detected external drive: $TARGET"
fi
# ── Verify mount point ───────────────────────────────────────────
[[ -d "$TARGET" ]] || fail "Target path '$TARGET' does not exist."
mountpoint -q "$TARGET" || fail "Target path '$TARGET' is not a mount point."
# ── Check free disk space (require ≥ 10 GB) ──────────────────────
FREE_KB=$(df -k --output=avail "$TARGET" | tail -1)
FREE_GB=$(( FREE_KB / 1024 / 1024 ))
log "Free space on drive: ${FREE_GB} GB"
(( FREE_GB >= MIN_FREE_GB )) || \
fail "Not enough free space on drive (${FREE_GB} GB available, ${MIN_FREE_GB} GB required)."
# ── Create timestamped backup directory ─────────────────────────
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
BACKUP_DIR="${TARGET}/Sovran_SystemsOS_Backup/${TIMESTAMP}"
mkdir -p "$BACKUP_DIR"
log "Backup destination: $BACKUP_DIR"
# ── Stage 1/4: NixOS configuration ──────────────────────────────
log ""
log "── Stage 1/4: NixOS configuration (/etc/nixos) ──────────────"
if [[ -d /etc/nixos ]]; then
rsync -a --info=progress2 /etc/nixos/ "$BACKUP_DIR/nixos/" 2>&1 | tee -a "$BACKUP_LOG" || \
fail "Stage 1 failed while copying /etc/nixos"
log "Stage 1 complete."
else
log "WARNING: /etc/nixos not found — skipping."
fi
# ── Stage 2/4: Secrets ──────────────────────────────────────────
log ""
log "── Stage 2/4: Secrets ───────────────────────────────────────"
mkdir -p "$BACKUP_DIR/secrets"
for SRC in /etc/nix-bitcoin-secrets /var/lib/domains; do
if [[ -e "$SRC" ]]; then
rsync -a --info=progress2 "$SRC" "$BACKUP_DIR/secrets/" 2>&1 | tee -a "$BACKUP_LOG" || \
log "WARNING: Could not copy $SRC — continuing."
else
log " (not found: $SRC — skipping)"
fi
done
# Hub state files from /var/lib/secrets/
if [[ -d /var/lib/secrets ]]; then
mkdir -p "$BACKUP_DIR/secrets/hub-state"
rsync -a --info=progress2 /var/lib/secrets/ "$BACKUP_DIR/secrets/hub-state/" 2>&1 | tee -a "$BACKUP_LOG" || \
log "WARNING: Could not copy /var/lib/secrets — continuing."
else
log " (not found: /var/lib/secrets — skipping)"
fi
log "Stage 2 complete."
# ── Stage 3/4: Home directory ────────────────────────────────────
log ""
log "── Stage 3/4: Home directory (/home) ───────────────────────"
if [[ -d /home ]]; then
rsync -a --info=progress2 \
--exclude='.cache/' \
--exclude='.local/share/Trash/' \
--exclude='*/Trash/' \
/home/ "$BACKUP_DIR/home/" 2>&1 | tee -a "$BACKUP_LOG" || \
fail "Stage 3 failed while copying /home"
log "Stage 3 complete."
else
log "WARNING: /home not found — skipping."
fi
# ── Stage 4/4: Wallet and node data ─────────────────────────────
log ""
log "── Stage 4/4: Wallet and node data (/var/lib/lnd) ──────────"
if [[ -d /var/lib/lnd ]]; then
rsync -a --info=progress2 \
--exclude='logs/' \
/var/lib/lnd/ "$BACKUP_DIR/lnd/" 2>&1 | tee -a "$BACKUP_LOG" || \
fail "Stage 4 failed while copying /var/lib/lnd"
log "Stage 4 complete."
else
log "WARNING: /var/lib/lnd not found — skipping."
fi
# ── Generate manifest ────────────────────────────────────────────
log ""
log "Generating BACKUP_MANIFEST.txt …"
{
echo "Sovran_SystemsOS Backup Manifest"
echo "Generated: $(date)"
echo "Hostname: $(hostname)"
echo "Target: $TARGET"
echo ""
echo "Contents:"
find "$BACKUP_DIR" -mindepth 1 -maxdepth 2 | sort
} > "$BACKUP_DIR/BACKUP_MANIFEST.txt"
log "Manifest written to $BACKUP_DIR/BACKUP_MANIFEST.txt"
# ── Done ─────────────────────────────────────────────────────────
log ""
log "All Finished! Please eject the drive before removing it from your Sovran Pro."
set_status "SUCCESS"

View File

@@ -40,6 +40,10 @@ REBUILD_LOG = "/var/log/sovran-hub-rebuild.log"
REBUILD_STATUS = "/var/log/sovran-hub-rebuild.status"
REBUILD_UNIT = "sovran-hub-rebuild.service"
BACKUP_LOG = "/var/log/sovran-hub-backup.log"
BACKUP_STATUS = "/var/log/sovran-hub-backup.status"
BACKUP_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts", "sovran-hub-backup.sh")
CUSTOM_NIX = "/etc/nixos/custom.nix"
HUB_BEGIN = " # ── Hub Managed (do not edit) ──────────────"
HUB_END = " # ── End Hub Managed ────────────────────────"
@@ -743,6 +747,66 @@ def _read_rebuild_log(offset: int = 0) -> tuple[str, int]:
return "", 0
# ── Backup helpers ────────────────────────────────────────────────
def _read_backup_status() -> str:
"""Read the backup status file. Returns RUNNING, SUCCESS, FAILED, or IDLE."""
try:
with open(BACKUP_STATUS, "r") as f:
return f.read().strip()
except FileNotFoundError:
return "IDLE"
def _read_backup_log(offset: int = 0) -> tuple[str, int]:
"""Read the backup log file from the given byte offset.
Returns (new_text, new_offset)."""
try:
with open(BACKUP_LOG, "rb") as f:
f.seek(0, 2)
size = f.tell()
if offset > size:
offset = 0
f.seek(offset)
chunk = f.read()
return chunk.decode(errors="replace"), offset + len(chunk)
except FileNotFoundError:
return "", 0
def _detect_external_drives() -> list[dict]:
"""Scan /run/media/ for mounted external drives.
Returns a list of dicts with name, path, free_gb, total_gb."""
drives = []
media_root = "/run/media"
if not os.path.isdir(media_root):
return drives
try:
for user in os.listdir(media_root):
user_path = os.path.join(media_root, user)
if not os.path.isdir(user_path):
continue
for drive in os.listdir(user_path):
drive_path = os.path.join(user_path, drive)
if not os.path.isdir(drive_path):
continue
try:
st = os.statvfs(drive_path)
total_gb = round((st.f_blocks * st.f_frsize) / (1024 ** 3), 1)
free_gb = round((st.f_bavail * st.f_frsize) / (1024 ** 3), 1)
drives.append({
"name": drive,
"path": drive_path,
"free_gb": free_gb,
"total_gb": total_gb,
})
except OSError:
pass
except OSError:
pass
return drives
# ── custom.nix Hub Managed section helpers ────────────────────────
def _read_hub_overrides() -> tuple[dict, str | None]:
@@ -1856,6 +1920,66 @@ async def api_support_audit_log(limit: int = 100):
return {"entries": lines}
# ── Backup endpoints ──────────────────────────────────────────────
@app.get("/api/backup/status")
async def api_backup_status(offset: int = 0):
"""Poll endpoint: reads backup status file + log file."""
loop = asyncio.get_event_loop()
status = await loop.run_in_executor(None, _read_backup_status)
new_log, new_offset = await loop.run_in_executor(None, _read_backup_log, offset)
running = (status == "RUNNING")
result = "pending" if running else status.lower()
return {
"running": running,
"result": result,
"log": new_log,
"offset": new_offset,
}
@app.get("/api/backup/drives")
async def api_backup_drives():
"""Return a list of detected external drives under /run/media/."""
loop = asyncio.get_event_loop()
drives = await loop.run_in_executor(None, _detect_external_drives)
return {"drives": drives}
@app.post("/api/backup/run")
async def api_backup_run(target: str = ""):
"""Start the backup script as a background subprocess.
Returns immediately; progress is read via /api/backup/status.
"""
loop = asyncio.get_event_loop()
status = await loop.run_in_executor(None, _read_backup_status)
if status == "RUNNING":
return {"ok": True, "status": "already_running"}
# Clear stale log before starting
try:
with open(BACKUP_LOG, "w") as f:
f.write("")
except OSError:
pass
env = dict(os.environ)
if target:
env["BACKUP_TARGET"] = target
# Fire-and-forget: the script writes its own status/log files.
# Progress is read by the client via /api/backup/status (same pattern
# as /api/updates/run and the rebuild feature).
await asyncio.create_subprocess_exec(
"/usr/bin/env", "bash", BACKUP_SCRIPT,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
env=env,
)
return {"ok": True, "status": "started"}
# ── Feature Manager endpoints ─────────────────────────────────────
@app.get("/api/features")

View File

@@ -360,3 +360,17 @@
border-color: #a8c8ff;
border-style: solid;
}
/* ── Manual Backup ───────────────────────────────────────────────── */
.support-backup-steps {
padding-left: 20px;
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.8;
margin: 8px 0 0 0;
}
.support-backup-steps li {
margin-bottom: 4px;
}

View File

@@ -259,3 +259,215 @@ function closeSupportModal() {
stopSupportTimer();
stopWalletUnlockTimer();
}
// ── Manual Backup modal ───────────────────────────────────────────
var _backupPollTimer = null;
var _backupLogOffset = 0;
function openBackupModal() {
if (!$supportModal) return;
$supportModal.classList.add("open");
$supportBody.innerHTML = '<p class="creds-loading">Detecting external drives\u2026</p>';
detectDrivesAndRender();
}
async function detectDrivesAndRender() {
try {
// Check whether a backup is already in progress
var status = await apiFetch("/api/backup/status?offset=0");
if (status.running) {
renderBackupRunning();
_backupLogOffset = status.offset || 0;
if (status.log) {
var logDiv = document.getElementById("backup-log");
if (logDiv) { logDiv.insertAdjacentText("beforeend", status.log); logDiv.scrollTop = logDiv.scrollHeight; }
}
startBackupPoll();
return;
}
} catch (_) {}
try {
var data = await apiFetch("/api/backup/drives");
renderBackupReady(data.drives || []);
} catch (err) {
$supportBody.innerHTML = '<p class="creds-empty">Could not detect drives. Please try again.</p>';
}
}
function renderBackupReady(drives) {
var driveSelector = "";
if (drives.length > 0) {
driveSelector = [
'<label class="support-info-label" style="display:block;margin-bottom:6px;">Select drive:</label>',
'<select id="backup-drive-select" class="support-unlock-select" style="width:100%;margin-bottom:14px;">',
].join("");
for (var i = 0; i < drives.length; i++) {
var d = drives[i];
driveSelector += '<option value="' + escHtml(d.path) + '">' +
escHtml(d.name) + ' \u2014 ' + d.free_gb + ' GB free / ' + d.total_gb + ' GB total' +
'</option>';
}
driveSelector += '</select>';
driveSelector += '<button class="btn support-btn-enable" id="btn-start-backup">Start Backup</button>';
} else {
driveSelector = [
'<div class="support-wallet-box support-wallet-warning">',
'<div class="support-wallet-header">',
'<span class="support-wallet-icon">\u26a0\ufe0f</span>',
'<span class="support-wallet-title">No External Drive Detected</span>',
'</div>',
'<p class="support-wallet-desc">',
'No USB drive was found under /run/media/. ',
'Make sure the drive is plugged in and mounted, then click Refresh.',
'</p>',
'</div>',
'<button class="btn support-btn-auditlog" id="btn-backup-refresh">&#x21bb; Refresh</button>',
].join("");
}
$supportBody.innerHTML = [
'<div class="support-section">',
'<div class="support-icon-big">\ud83d\udcbe</div>',
'<h3 class="support-heading">Manual Backup</h3>',
'<p class="support-desc">Back up your Sovran_SystemsOS data to an external USB hard drive.</p>',
'<div class="support-steps">',
'<div class="support-steps-title">Requirements</div>',
'<ol class="support-backup-steps">',
'<li>USB hard drive plugged into one of the open USB ports on your Sovran Pro</li>',
'<li>At least 500 GB of free space on the drive</li>',
'<li>Drive must be formatted as <strong>exFAT</strong></li>',
'</ol>',
'</div>',
'<div class="support-steps">',
'<div class="support-steps-title">What gets backed up</div>',
'<ol class="support-backup-steps">',
'<li>NixOS configuration (<code>/etc/nixos</code>)</li>',
'<li>Bitcoin &amp; Lightning wallet data (<code>/var/lib/lnd</code>)</li>',
'<li>nix-bitcoin secrets (<code>/etc/nix-bitcoin-secrets</code>)</li>',
'<li>Domain configurations (<code>/var/lib/domains</code>)</li>',
'<li>Home directory (<code>/home</code>)</li>',
'</ol>',
'</div>',
'<div class="support-wallet-box support-wallet-protected">',
'<div class="support-wallet-header">',
'<span class="support-wallet-icon">\u23f1\ufe0f</span>',
'<span class="support-wallet-title">Time Estimate</span>',
'</div>',
'<p class="support-wallet-desc">This backup can take <strong>up to 4 hours</strong> depending on the amount of data stored on your Sovran Pro and the speed of your external hard drive. Be patient\u2026</p>',
'</div>',
driveSelector,
'</div>',
].join("");
if (drives.length > 0) {
document.getElementById("btn-start-backup").addEventListener("click", startBackup);
} else {
document.getElementById("btn-backup-refresh").addEventListener("click", function() {
$supportBody.innerHTML = '<p class="creds-loading">Detecting external drives\u2026</p>';
detectDrivesAndRender();
});
}
}
async function startBackup() {
var btn = document.getElementById("btn-start-backup");
if (btn) { btn.disabled = true; btn.textContent = "Starting\u2026"; }
var sel = document.getElementById("backup-drive-select");
var target = sel ? sel.value : "";
try {
_backupLogOffset = 0;
await apiFetch("/api/backup/run" + (target ? "?target=" + encodeURIComponent(target) : ""), { method: "POST" });
renderBackupRunning();
startBackupPoll();
} catch (err) {
if (btn) { btn.disabled = false; btn.textContent = "Start Backup"; }
alert("Failed to start backup: " + (err.message || "Unknown error"));
}
}
function renderBackupRunning() {
$supportBody.innerHTML = [
'<div class="support-section">',
'<div class="support-icon-big support-active-icon">\ud83d\udcbe</div>',
'<h3 class="support-heading support-active-heading">Backup In Progress</h3>',
'<div class="support-wallet-box support-wallet-warning">',
'<div class="support-wallet-header">',
'<span class="support-wallet-icon">\u26a0\ufe0f</span>',
'<span class="support-wallet-title">Do Not Unplug</span>',
'</div>',
'<p class="support-wallet-desc">Do not remove the USB drive while the backup is running. This could corrupt the backup and your drive.</p>',
'</div>',
'<div class="modal-log" id="backup-log" style="text-align:left;"></div>',
'</div>',
].join("");
}
function startBackupPoll() {
stopBackupPoll();
_backupPollTimer = setInterval(pollBackupStatus, 2000);
pollBackupStatus();
}
function stopBackupPoll() {
if (_backupPollTimer) { clearInterval(_backupPollTimer); _backupPollTimer = null; }
}
async function pollBackupStatus() {
try {
var data = await apiFetch("/api/backup/status?offset=" + _backupLogOffset);
var logDiv = document.getElementById("backup-log");
if (logDiv && data.log) {
logDiv.insertAdjacentText("beforeend", data.log);
logDiv.scrollTop = logDiv.scrollHeight;
}
_backupLogOffset = data.offset;
if (!data.running) {
stopBackupPoll();
renderBackupDone(data.result === "success");
}
} catch (_) {}
}
function renderBackupDone(success) {
var logDiv = document.getElementById("backup-log");
var logContent = logDiv ? logDiv.textContent : "";
if (success) {
$supportBody.innerHTML = [
'<div class="support-section">',
'<div class="support-icon-big">\u2705</div>',
'<h3 class="support-heading">All Finished!</h3>',
'<div class="support-wallet-box support-wallet-protected">',
'<div class="support-wallet-header">',
'<span class="support-wallet-icon">\u23cf\ufe0f</span>',
'<span class="support-wallet-title">Eject Your Drive</span>',
'</div>',
'<p class="support-wallet-desc">Please eject the drive before removing it from your Sovran Pro.</p>',
'</div>',
'<div class="modal-log" id="backup-log-done" style="text-align:left;"></div>',
'<button class="btn support-btn-done" id="btn-backup-close">Close</button>',
'</div>',
].join("");
var doneLog = document.getElementById("backup-log-done");
if (doneLog) { doneLog.textContent = logContent; doneLog.scrollTop = doneLog.scrollHeight; }
} else {
$supportBody.innerHTML = [
'<div class="support-section">',
'<div class="support-icon-big">\u26a0\ufe0f</div>',
'<h3 class="support-heading">Backup Failed</h3>',
'<p class="support-desc">The backup did not complete successfully. Please check that the USB drive is still connected, has enough free space, and is formatted as exFAT. Then try again.</p>',
'<div class="modal-log" id="backup-log-fail" style="text-align:left;"></div>',
'<button class="btn support-btn-done" id="btn-backup-close">Close</button>',
'</div>',
].join("");
var failLog = document.getElementById("backup-log-fail");
if (failLog) { failLog.textContent = logContent; failLog.scrollTop = failLog.scrollHeight; }
}
document.getElementById("btn-backup-close").addEventListener("click", closeSupportModal);
}

View File

@@ -58,11 +58,22 @@ function renderSidebarSupport(supportServices) {
btn.addEventListener("click", function() { openSupportModal(); });
$sidebarSupport.appendChild(btn);
}
if (supportServices.length > 0) {
// ── Manual Backup button
var backupBtn = document.createElement("button");
backupBtn.className = "sidebar-support-btn";
backupBtn.innerHTML =
'<span class="sidebar-support-icon">💾</span>' +
'<span class="sidebar-support-text">' +
'<span class="sidebar-support-title">Manual Backup</span>' +
'<span class="sidebar-support-hint">Back up to external drive</span>' +
'</span>';
backupBtn.addEventListener("click", function() { openBackupModal(); });
$sidebarSupport.appendChild(backupBtn);
var hr = document.createElement("hr");
hr.className = "sidebar-divider";
$sidebarSupport.appendChild(hr);
}
}
function buildTile(svc) {