feat(backup): include secrets (LND seed key) in backups + Download button
Some checks failed
Demo images / Build & push demo images (push) Failing after 1m32s
Some checks failed
Demo images / Build & push demo images (push) Failing after 1m32s
The full backup carried identity/lnd_aezeed.enc but NOT the per-node wallet secret that encrypts it (secrets/lnd-wallet-password), so a restored node could never decrypt its Lightning seed. The secrets dir now rides the backup (archive v3; the .bak itself is passphrase-encrypted). Restore gains a staging-presence guard so restoring an older archive without a secrets dir can no longer displace and delete the node's live secrets — covered by two new tests. Backups can now also be downloaded through the browser: a session-gated GET /api/blob/backup/<id> endpoint (under the existing nginx /api/blob prefix, so no fleet nginx changes) plus a Download button next to Verify / USB / Restore / Delete in Settings → Backup & Restore. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
0aa9463b36
commit
a687df9bd9
@ -194,6 +194,39 @@ impl ApiHandler {
|
||||
))
|
||||
}
|
||||
|
||||
/// Serve an encrypted backup archive (`<data_dir>/backups/<id>.bak`) as a
|
||||
/// browser download. The archive is passphrase-encrypted at rest; the
|
||||
/// session gate at the route controls who can fetch it.
|
||||
async fn handle_backup_download(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||
let id = path.strip_prefix("/api/blob/backup/").unwrap_or("");
|
||||
// Backup ids are UUIDs — reject anything that could traverse paths.
|
||||
if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"invalid backup id"}"#),
|
||||
));
|
||||
}
|
||||
let file = self.config.data_dir.join("backups").join(format!("{id}.bak"));
|
||||
match tokio::fs::read(&file).await {
|
||||
Ok(bytes) => Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
format!("attachment; filename=\"archipelago-backup-{id}.bak\""),
|
||||
)
|
||||
.header("Content-Length", bytes.len())
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::from("Internal error")))),
|
||||
Err(_) => Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"backup not found"}"#),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a 401 Unauthorized JSON response.
|
||||
fn unauthorized() -> Response<hyper::Body> {
|
||||
let body = serde_json::json!({ "error": "Unauthorized" });
|
||||
@ -434,6 +467,16 @@ impl ApiHandler {
|
||||
Self::handle_mesh_typed_relay(self.rpc_handler.clone(), body_bytes).await
|
||||
}
|
||||
|
||||
// Backup archive download — session-gated. Lives under /api/blob/
|
||||
// so the existing nginx `location /api/blob` prefix proxies it on
|
||||
// every fleet node without a config change.
|
||||
(Method::GET, p) if p.starts_with("/api/blob/backup/") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
self.handle_backup_download(p).await
|
||||
}
|
||||
|
||||
// Blob upload — local/session use only. Session-authenticated so
|
||||
// only the node owner can push attachments into the blob store.
|
||||
(Method::POST, "/api/blob") => {
|
||||
|
||||
@ -29,6 +29,11 @@ const BACKUP_DIRS: &[&str] = &[
|
||||
"credentials",
|
||||
"tor-config",
|
||||
"content",
|
||||
// Per-node service secrets. Without these a restored node can't
|
||||
// decrypt identity/lnd_aezeed.enc (the Lightning seed backup is
|
||||
// encrypted with secrets/lnd-wallet-password) or reuse its service
|
||||
// credentials. The archive itself is passphrase-encrypted.
|
||||
"secrets",
|
||||
];
|
||||
|
||||
/// Files within data_dir to include in a full backup.
|
||||
@ -101,7 +106,9 @@ pub async fn create_full_backup(
|
||||
// Step 4: Write metadata
|
||||
let metadata = BackupMetadata {
|
||||
id: backup_id,
|
||||
version: 2,
|
||||
// v3: archive additionally carries the `secrets` dir (needed to
|
||||
// decrypt identity/lnd_aezeed.enc on a restored node).
|
||||
version: 3,
|
||||
created_at: timestamp,
|
||||
encrypted: true,
|
||||
size_bytes: encrypted.len() as u64,
|
||||
@ -193,6 +200,14 @@ pub async fn restore_full_backup(data_dir: &Path, backup_id: &str, passphrase: &
|
||||
.context("Failed to create rollback directory")?;
|
||||
|
||||
for dir_name in BACKUP_DIRS {
|
||||
// Only displace live data the backup will actually replace —
|
||||
// older archives don't contain every current BACKUP_DIRS entry
|
||||
// (e.g. `secrets` was added later), and moving a live dir to
|
||||
// rollback with nothing staged to take its place would DELETE it
|
||||
// at cleanup time.
|
||||
if !staging_dir.join(dir_name).exists() {
|
||||
continue;
|
||||
}
|
||||
let src = data_dir.join(dir_name);
|
||||
if src.exists() {
|
||||
let dst = rollback_dir.join(dir_name);
|
||||
@ -207,6 +222,9 @@ pub async fn restore_full_backup(data_dir: &Path, backup_id: &str, passphrase: &
|
||||
}
|
||||
}
|
||||
for file_name in BACKUP_FILES {
|
||||
if !staging_dir.join(file_name).exists() {
|
||||
continue;
|
||||
}
|
||||
let src = data_dir.join(file_name);
|
||||
if src.exists() {
|
||||
let dst = rollback_dir.join(file_name);
|
||||
@ -732,6 +750,46 @@ mod tests {
|
||||
assert!(!bad_result.valid);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn secrets_dir_rides_backup_and_restore() {
|
||||
// The Lightning seed backup (identity/lnd_aezeed.enc) is encrypted
|
||||
// with secrets/lnd-wallet-password — a backup that omits it can't
|
||||
// recover the Lightning wallet on restore.
|
||||
let dir = TempDir::new().unwrap();
|
||||
setup_data_dir(dir.path());
|
||||
std::fs::create_dir_all(dir.path().join("secrets")).unwrap();
|
||||
std::fs::write(dir.path().join("secrets/lnd-wallet-password"), "s3cret").unwrap();
|
||||
|
||||
let meta = create_full_backup(dir.path(), "pass", None).await.unwrap();
|
||||
|
||||
std::fs::remove_dir_all(dir.path().join("secrets")).unwrap();
|
||||
restore_full_backup(dir.path(), &meta.id, "pass").await.unwrap();
|
||||
|
||||
let pw = std::fs::read_to_string(dir.path().join("secrets/lnd-wallet-password")).unwrap();
|
||||
assert_eq!(pw, "s3cret");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restoring_old_backup_without_secrets_keeps_live_secrets() {
|
||||
// Archives created before `secrets` joined BACKUP_DIRS don't stage
|
||||
// one — the restore must NOT displace (and then delete) the node's
|
||||
// live secrets in that case.
|
||||
let dir = TempDir::new().unwrap();
|
||||
setup_data_dir(dir.path());
|
||||
|
||||
// Backup taken while no secrets dir existed (mimics an old archive).
|
||||
let meta = create_full_backup(dir.path(), "pass", None).await.unwrap();
|
||||
|
||||
// Live secrets appear afterwards.
|
||||
std::fs::create_dir_all(dir.path().join("secrets")).unwrap();
|
||||
std::fs::write(dir.path().join("secrets/lnd-wallet-password"), "keep-me").unwrap();
|
||||
|
||||
restore_full_backup(dir.path(), &meta.id, "pass").await.unwrap();
|
||||
|
||||
let pw = std::fs::read_to_string(dir.path().join("secrets/lnd-wallet-password")).unwrap();
|
||||
assert_eq!(pw, "keep-me");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_and_restore() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@ -126,6 +126,31 @@ async function deleteBackup(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Download the encrypted backup archive through the browser — the only
|
||||
// path off the node for remote/companion users with no USB access.
|
||||
const downloadingId = ref<string | null>(null)
|
||||
async function downloadBackup(id: string) {
|
||||
downloadingId.value = id
|
||||
try {
|
||||
const res = await fetch(`/api/blob/backup/${encodeURIComponent(id)}`, { credentials: 'include' })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `archipelago-backup-${id}.bak`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
showBackupStatus('Backup download started', 'success')
|
||||
} catch {
|
||||
showBackupStatus('Backup download failed', 'error')
|
||||
} finally {
|
||||
downloadingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Recovery phrase reveal — re-auth gated (password + 2FA when enabled).
|
||||
const showRevealModal = ref(false)
|
||||
const revealPassword = ref('')
|
||||
@ -364,6 +389,9 @@ defineExpose({ loadBackups })
|
||||
<button @click="backupToUsb(b.id)" :disabled="usbCopyingId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-blue-400 disabled:opacity-50" :title="t('settings.copyToUsb')">
|
||||
{{ usbCopyingId === b.id ? '...' : 'USB' }}
|
||||
</button>
|
||||
<button @click="downloadBackup(b.id)" :disabled="downloadingId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-green-400 disabled:opacity-50" title="Download backup file">
|
||||
{{ downloadingId === b.id ? '...' : 'Download' }}
|
||||
</button>
|
||||
<button @click="confirmRestoreBackup(b.id)" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-orange-400" :title="t('common.restore')">
|
||||
{{ t('common.restore') }}
|
||||
</button>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user