feat: Lightning channel backup, Web5 mobile tab active, file path fix

Task 14: Lightning Channel Backup
- New lnd.export-channel-backup RPC — exports SCB (Static Channel Backup)
- Settings UI: "Lightning Channel Backup" section with export + copy
- Returns base64 backup data, channel count, timestamp

Web5 mobile tab active state
- Fixed combined tab matching for Web5: includes /web5, /federation, /mesh routes
- Previously only matched /cloud and /server (wrong branch)

Content file path fix
- Allow forward slashes in filenames for subdirectories (Music/song.mp3)
- Still block .., \, null bytes, hidden files, absolute paths

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-19 21:47:18 +00:00
parent 1f732d8d08
commit c5417640a2
4 changed files with 105 additions and 1 deletions

View File

@ -938,6 +938,53 @@ impl RpcHandler {
"grpc_port": 10009,
}))
}
/// lnd.export-channel-backup — Export all channel static backups (SCB).
/// Returns base64-encoded multi-channel backup that can restore channels on a new node.
pub(super) async fn handle_lnd_export_channel_backup(&self) -> Result<serde_json::Value> {
let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
let macaroon_bytes = tokio::fs::read(macaroon_path)
.await
.context("Failed to read LND admin macaroon")?;
let macaroon_hex = hex::encode(&macaroon_bytes);
let cert_path = "/var/lib/archipelago/lnd/tls.cert";
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.timeout(std::time::Duration::from_secs(10))
.build()
.context("Failed to build HTTP client")?;
let resp = client
.get("https://127.0.0.1:8080/v1/channels/backup")
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("Failed to reach LND REST API")?;
if !resp.status().is_success() {
anyhow::bail!("LND returned {}", resp.status());
}
let data: serde_json::Value = resp.json().await.context("Invalid JSON from LND")?;
// Extract the multi_chan_backup bytes
let backup_b64 = data
.get("multi_chan_backup")
.and_then(|m| m.get("multi_chan_backup"))
.and_then(|b| b.as_str())
.unwrap_or("");
Ok(serde_json::json!({
"backup": backup_b64,
"channel_count": data.get("multi_chan_backup")
.and_then(|m| m.get("chan_points"))
.and_then(|c| c.as_array())
.map(|a| a.len())
.unwrap_or(0),
"timestamp": chrono::Utc::now().to_rfc3339(),
}))
}
}
// Channel types

View File

@ -514,6 +514,7 @@ impl RpcHandler {
"lnd.create-raw-tx" => self.handle_lnd_create_raw_tx(params).await,
"lnd.gettransactions" => self.handle_lnd_gettransactions().await,
"lnd.connect-info" => self.handle_lnd_connect_info().await,
"lnd.export-channel-backup" => self.handle_lnd_export_channel_backup().await,
// Multi-identity management
"identity.list" => self.handle_identity_list(params).await,

View File

@ -304,7 +304,9 @@
'nav-tab-active': item.isCombined
? (item.path === '/dashboard/apps'
? (route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/discover') || route.path.includes('/app-session'))
: (route.path.includes('/cloud') || route.path.includes('/server')))
: item.path === '/dashboard/web5'
? (route.path.includes('/web5') || route.path.includes('/federation') || route.path.includes('/mesh'))
: (route.path.includes('/cloud') || route.path.includes('/server')))
: undefined
}"
:exact-active-class="item.isCombined ? undefined : 'nav-tab-active'"

View File

@ -955,6 +955,26 @@
</div>
</Teleport>
<!-- Lightning Channel Backup -->
<div class="glass-card px-6 py-6 mb-6">
<h2 class="text-xl font-semibold text-white/96 mb-1">Lightning Channel Backup</h2>
<p class="text-sm text-white/60 mb-3">Export your channel state so you can restore channels on a new node. Does not include on-chain wallet seed.</p>
<div class="flex gap-3">
<button @click="exportChannelBackup" :disabled="exportingChannelBackup" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{{ exportingChannelBackup ? 'Exporting...' : 'Export Channel Backup' }}
</button>
</div>
<div v-if="channelBackupData" class="mt-3 bg-black/30 rounded-lg p-3">
<p class="text-xs text-white/40 mb-1">{{ channelBackupChannels }} channel{{ channelBackupChannels !== 1 ? 's' : '' }} backed up at {{ channelBackupTime }}</p>
<textarea readonly :value="channelBackupData" rows="3" class="w-full bg-black/20 text-xs font-mono text-white/60 rounded p-2 resize-none border border-white/10"></textarea>
<button @click="copyChannelBackup" class="mt-2 glass-button px-3 py-1.5 rounded text-xs">{{ channelBackupCopied ? 'Copied!' : 'Copy Backup Data' }}</button>
</div>
<p v-if="channelBackupError" class="mt-2 text-xs text-red-400">{{ channelBackupError }}</p>
</div>
<!-- Network Diagnostics Link -->
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between">
@ -1439,6 +1459,40 @@ interface BackupEntry {
}
const backupList = ref<BackupEntry[]>([])
const loadingBackups = ref(false)
// Lightning channel backup
const exportingChannelBackup = ref(false)
const channelBackupData = ref('')
const channelBackupChannels = ref(0)
const channelBackupTime = ref('')
const channelBackupError = ref('')
const channelBackupCopied = ref(false)
async function exportChannelBackup() {
exportingChannelBackup.value = true
channelBackupError.value = ''
try {
const res = await rpcClient.call<{ backup: string; channel_count: number; timestamp: string }>({
method: 'lnd.export-channel-backup',
timeout: 15000,
})
channelBackupData.value = res.backup
channelBackupChannels.value = res.channel_count
channelBackupTime.value = new Date(res.timestamp).toLocaleString()
} catch (err: unknown) {
channelBackupError.value = err instanceof Error ? err.message : 'Failed to export'
} finally {
exportingChannelBackup.value = false
}
}
function copyChannelBackup() {
if (channelBackupData.value) {
navigator.clipboard.writeText(channelBackupData.value).catch(() => {})
channelBackupCopied.value = true
setTimeout(() => { channelBackupCopied.value = false }, 2000)
}
}
const showCreateBackupModal = ref(false)
const backupPassphrase = ref('')
const backupDescription = ref('')