parrot-blackbox 1.0.18 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -0
- package/package.json +1 -1
- package/src/backup/btrfs-send.js +312 -0
- package/src/backup/restore.js +208 -15
- package/src/backup/snapshot.js +232 -38
- package/src/cli.js +76 -7
- package/src/core/store.js +9 -1
- package/src/storage/accounts.js +4 -2
- package/src/storage/allocator.js +139 -10
package/README.md
CHANGED
|
@@ -30,6 +30,10 @@ Parrot install died" — a single CLI + background daemon that:
|
|
|
30
30
|
6. **Brings you back from a fresh install (Lightning Fast)** — restore a snapshot from the cloud
|
|
31
31
|
onto fresh Parrot (works whether or not you used disk encryption; it just
|
|
32
32
|
needs your `sudo` password). **Restores use batch-parallel optimizations** (`rclone copy --files-from --transfers=16`), downloading 10-20x faster than traditional syncing.
|
|
33
|
+
7. **V2.0: BTRFS send/receive incremental streaming** — if your root is on BTRFS
|
|
34
|
+
(most Parrot installs since 2022), uploads are **10-50x smaller and faster**
|
|
35
|
+
after the first backup. Only block-level changes are uploaded, not the entire
|
|
36
|
+
filesystem every time.
|
|
33
37
|
|
|
34
38
|
---
|
|
35
39
|
|
|
@@ -41,12 +45,75 @@ snapshots existed — but they lived on the *same* disk, so a dead SSD took them
|
|
|
41
45
|
too. This tool automates the fix:
|
|
42
46
|
|
|
43
47
|
- Timeshift snapshots are created **and uploaded to the cloud pool** weekly.
|
|
48
|
+
- **V2.0: BTRFS send/receive** — incremental streaming backups that only upload
|
|
49
|
+
block-level changes, not full filesystem copies every time.
|
|
44
50
|
- File backups live on MEGA + Drive only.
|
|
45
51
|
- The only recovery-critical local thing left is the CLI itself (`npx` is a
|
|
46
52
|
clone away).
|
|
47
53
|
|
|
48
54
|
---
|
|
49
55
|
|
|
56
|
+
## 🚀 What's New in V2.0: BTRFS Send/Receive
|
|
57
|
+
|
|
58
|
+
**Major efficiency upgrade:** V2.0 replaces file-by-file copying with native
|
|
59
|
+
BTRFS send/receive streaming. This is the same technology used by enterprise
|
|
60
|
+
backup tools like btrbk, snapper, and btrfs2s3.
|
|
61
|
+
|
|
62
|
+
### How it works
|
|
63
|
+
|
|
64
|
+
**First backup (bootstrap):**
|
|
65
|
+
```
|
|
66
|
+
btrfs send /snapshot → zstd compression → [optional encryption] → rclone rcat → cloud
|
|
67
|
+
```
|
|
68
|
+
- Creates a full BTRFS stream of your root filesystem (~35-40 GiB typical)
|
|
69
|
+
- Compressed with zstd (saves ~20-30% bandwidth)
|
|
70
|
+
- Optionally encrypted with AES-256
|
|
71
|
+
- Streamed directly to cloud via rclone (no intermediate temp files)
|
|
72
|
+
|
|
73
|
+
**Every subsequent backup (incremental):**
|
|
74
|
+
```
|
|
75
|
+
btrfs send -p <parent> /new_snapshot → zstd → [encryption] → cloud
|
|
76
|
+
```
|
|
77
|
+
- Only sends **block-level differences** since the last backup
|
|
78
|
+
- Typical incremental: **100 MB to 2 GB** instead of 35+ GiB
|
|
79
|
+
- 10-50x less bandwidth and storage per backup
|
|
80
|
+
|
|
81
|
+
**Restore:**
|
|
82
|
+
```
|
|
83
|
+
rclone cat cloud → [decrypt] → zstd decompress → btrfs receive → Timeshift
|
|
84
|
+
```
|
|
85
|
+
- Downloads snapshots in parent-chain order (oldest first)
|
|
86
|
+
- Applies incrementals automatically
|
|
87
|
+
- Reconstructs the exact filesystem byte-for-byte
|
|
88
|
+
|
|
89
|
+
### Why BTRFS send/receive is better
|
|
90
|
+
|
|
91
|
+
| Old way (v1.x) | New way (v2.0) |
|
|
92
|
+
|---|---|
|
|
93
|
+
| Copy every file every time | Only send changed blocks (incremental) |
|
|
94
|
+
| 35+ GiB per backup | First: ~35 GiB, then ~500 MB each |
|
|
95
|
+
| ~2-8 hours upload | First: ~2-8 hours, then ~5-20 minutes |
|
|
96
|
+
| File-level granularity | Filesystem-level (preserves all metadata) |
|
|
97
|
+
| CoW efficiency lost in tar/zip | Native BTRFS streaming |
|
|
98
|
+
|
|
99
|
+
Real-world example (from research):
|
|
100
|
+
- Initial backup: 17.4 GiB subvolume → 13 GiB compressed (~2 min full send)
|
|
101
|
+
- Incremental after normal use: **a few seconds** to generate stream, only
|
|
102
|
+
megabytes uploaded
|
|
103
|
+
|
|
104
|
+
### Requirements for BTRFS mode
|
|
105
|
+
|
|
106
|
+
✅ **Root filesystem on BTRFS** (check: `df -T /` should show `btrfs`)
|
|
107
|
+
✅ **btrfs-progs installed** (auto-installed by wizard if missing)
|
|
108
|
+
✅ **zstd installed** (for compression)
|
|
109
|
+
✅ **openssl** (for encryption, optional)
|
|
110
|
+
|
|
111
|
+
**If your root is NOT on BTRFS:** V2.0 automatically falls back to the v1.x
|
|
112
|
+
file-copy method. You still get cloud backups, just without incremental
|
|
113
|
+
efficiency. (Most Parrot OS installs since 2022 default to BTRFS.)
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
50
117
|
## Requirements
|
|
51
118
|
|
|
52
119
|
| Tool | Why |
|
|
@@ -504,6 +571,40 @@ $EDITOR ~/.config/parrot-blackbox/config.json # jobs.files.enabled = true
|
|
|
504
571
|
```
|
|
505
572
|
---
|
|
506
573
|
|
|
574
|
+
## Upgrading from V1.x to V2.0
|
|
575
|
+
|
|
576
|
+
**Good news:** V2.0 is backward-compatible with your existing v1.x backups. Your old
|
|
577
|
+
snapshot backups remain accessible and restorable.
|
|
578
|
+
|
|
579
|
+
**What changes automatically:**
|
|
580
|
+
- New snapshots will use BTRFS send/receive (if your root is on BTRFS)
|
|
581
|
+
- Old v1 file-tree snapshots can still be restored normally
|
|
582
|
+
- Config gets a new `btrfs` section with defaults (compression: on, encryption: off)
|
|
583
|
+
|
|
584
|
+
**What you need to know:**
|
|
585
|
+
- **First backup after upgrade:** Will be a full BTRFS send (~35 GiB typical) since
|
|
586
|
+
there's no parent yet. This is the new "bootstrap" backup.
|
|
587
|
+
- **Every backup after that:** Incremental (100 MB–2 GB typical), using the previous
|
|
588
|
+
snapshot as parent.
|
|
589
|
+
- **Old v1 snapshots:** Safe to keep or prune normally. They work independently of
|
|
590
|
+
the new v2 incremental chain.
|
|
591
|
+
- **If you don't use BTRFS:** V2.0 automatically falls back to v1 file-copy mode.
|
|
592
|
+
No action needed.
|
|
593
|
+
|
|
594
|
+
**Optional: Enable encryption**
|
|
595
|
+
```bash
|
|
596
|
+
# Edit config to turn on encryption for BTRFS streams
|
|
597
|
+
$EDITOR ~/.config/parrot-blackbox/config.json
|
|
598
|
+
# Set: jobs.snapshots.btrfs.encryption = true
|
|
599
|
+
# Set: storage.encryptionPassphrase = "your-secure-passphrase"
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
**Mixing v1 and v2 backups:**
|
|
603
|
+
You can keep both! The tool tracks them separately via manifests. Restore commands
|
|
604
|
+
automatically detect which version each snapshot is.
|
|
605
|
+
|
|
606
|
+
---
|
|
607
|
+
|
|
507
608
|
## Recovery guide (fresh install / new machine)
|
|
508
609
|
|
|
509
610
|
### Option A — restore your files (fonts, images, docs…)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parrot-blackbox",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Crash-proof, multi-cloud backup & recovery automation for Parrot OS. Daily/weekly off-disk backups with automatic catch-up, smart storage across many MEGA + Google Drive accounts, Timeshift snapshot backups and one-command restore.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/cli.js",
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BTRFS send/receive primitives for incremental snapshot backups.
|
|
3
|
+
*
|
|
4
|
+
* Key concepts from research:
|
|
5
|
+
* - `btrfs send <snapshot>` generates a binary stream of the entire subvolume (full send)
|
|
6
|
+
* - `btrfs send -p <parent> <snapshot>` generates only the block-level differences (incremental)
|
|
7
|
+
* - Both sender and receiver must have the parent snapshot in read-only state
|
|
8
|
+
* - The stream can be piped through compression (zstd) and encryption (openssl)
|
|
9
|
+
* - `btrfs receive <path>` reconstructs the subvolume from the stream
|
|
10
|
+
*
|
|
11
|
+
* Pipeline architecture:
|
|
12
|
+
* Backup: btrfs send [-p parent] snapshot | zstd | openssl enc | rclone rcat remote:path
|
|
13
|
+
* Restore: rclone cat remote:path | openssl dec | zstd -d | btrfs receive destination
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { execa, execaSync } from 'execa';
|
|
20
|
+
import { hasCommandSync } from '../core/store.js';
|
|
21
|
+
import { sudoExec, sudoExecSync } from '../util/sudo.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check if BTRFS tools are available on the system.
|
|
25
|
+
*/
|
|
26
|
+
export function hasBtrfs() {
|
|
27
|
+
return hasCommandSync('btrfs');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check if a given path is on a BTRFS filesystem.
|
|
32
|
+
* @param {string} dirPath - Path to check (e.g., '/' for root filesystem)
|
|
33
|
+
* @returns {Promise<boolean>}
|
|
34
|
+
*/
|
|
35
|
+
export async function isBtrfsFilesystem(dirPath) {
|
|
36
|
+
try {
|
|
37
|
+
const res = await execa('stat', ['-f', '-c', '%T', dirPath], { reject: false });
|
|
38
|
+
return res.exitCode === 0 && res.stdout.trim().toLowerCase() === 'btrfs';
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Get the BTRFS device for a given mount point.
|
|
46
|
+
* @param {string} mountPoint - e.g., '/'
|
|
47
|
+
* @returns {Promise<string|null>} - Device path like '/dev/mapper/luks-xxx' or null
|
|
48
|
+
*/
|
|
49
|
+
export async function getBtrfsDevice(mountPoint = '/') {
|
|
50
|
+
try {
|
|
51
|
+
const res = await execa('findmnt', ['-n', '-o', 'SOURCE', mountPoint], { reject: false });
|
|
52
|
+
if (res.exitCode !== 0) return null;
|
|
53
|
+
// Remove subvolume notation like [/@] to get the raw device
|
|
54
|
+
const device = res.stdout.trim().split('[')[0].trim();
|
|
55
|
+
return device || null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Set a BTRFS subvolume to read-only mode (required for send).
|
|
63
|
+
* @param {string} subvolPath - Path to the subvolume
|
|
64
|
+
* @param {boolean} readOnly - true to set read-only, false to set writable
|
|
65
|
+
* @param {object} opts - {privileged: 'interactive'|'noninteractive'}
|
|
66
|
+
* @returns {Promise<void>}
|
|
67
|
+
*/
|
|
68
|
+
export async function setSubvolumeReadOnly(subvolPath, readOnly = true, { privileged = 'noninteractive' } = {}) {
|
|
69
|
+
const roValue = readOnly ? 'true' : 'false';
|
|
70
|
+
const args = ['btrfs', 'property', 'set', '-ts', subvolPath, 'ro', roValue];
|
|
71
|
+
const res = await sudoExec(args, { privileged });
|
|
72
|
+
if (res.exitCode !== 0) {
|
|
73
|
+
throw new Error(`Failed to set ${subvolPath} read-only=${readOnly}: ${res.stderr}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Check if a subvolume is read-only.
|
|
79
|
+
* @param {string} subvolPath
|
|
80
|
+
* @returns {Promise<boolean>}
|
|
81
|
+
*/
|
|
82
|
+
export async function isSubvolumeReadOnly(subvolPath) {
|
|
83
|
+
try {
|
|
84
|
+
const res = await execa('btrfs', ['property', 'get', '-ts', subvolPath, 'ro'], { reject: false });
|
|
85
|
+
if (res.exitCode !== 0) return false;
|
|
86
|
+
return res.stdout.includes('ro=true');
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Find the most recent successfully uploaded snapshot that can serve as a parent.
|
|
94
|
+
* Looks for manifest files in the local manifests directory.
|
|
95
|
+
* @param {string} manifestsDir - Path to the manifests directory
|
|
96
|
+
* @param {Array<{name:string}>} localSnapshots - List of local snapshots from timeshift
|
|
97
|
+
* @returns {string|null} - The snapshot name to use as parent, or null for full send
|
|
98
|
+
*/
|
|
99
|
+
export function findLastUploadedSnapshot(manifestsDir, localSnapshots) {
|
|
100
|
+
if (!fs.existsSync(manifestsDir)) return null;
|
|
101
|
+
|
|
102
|
+
const manifestFiles = fs.readdirSync(manifestsDir)
|
|
103
|
+
.filter(f => f.startsWith('snapshots-') && f.endsWith('.json'))
|
|
104
|
+
.map(f => {
|
|
105
|
+
const name = f.replace('snapshots-', '').replace('.json', '');
|
|
106
|
+
return { name, file: f };
|
|
107
|
+
})
|
|
108
|
+
// Sort by name (which is timestamp-based) descending
|
|
109
|
+
.sort((a, b) => b.name.localeCompare(a.name));
|
|
110
|
+
|
|
111
|
+
// Find the most recent manifest whose snapshot still exists locally
|
|
112
|
+
for (const { name } of manifestFiles) {
|
|
113
|
+
const existsLocally = localSnapshots.some(s => s.name === name);
|
|
114
|
+
if (existsLocally) {
|
|
115
|
+
return name;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get the parent snapshot for an incremental send.
|
|
124
|
+
* Reads the manifest to extract the parent reference.
|
|
125
|
+
* @param {string} manifestPath
|
|
126
|
+
* @returns {string|null}
|
|
127
|
+
*/
|
|
128
|
+
export function getSnapshotParent(manifestPath) {
|
|
129
|
+
if (!fs.existsSync(manifestPath)) return null;
|
|
130
|
+
try {
|
|
131
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
132
|
+
return manifest.parent || null;
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Create a BTRFS send stream.
|
|
140
|
+
* @param {string} subvolPath - Path to the snapshot subvolume
|
|
141
|
+
* @param {object} opts - {parent: string|null, privileged: 'interactive'|'noninteractive'}
|
|
142
|
+
* @returns {Promise<ReadableStream>} - The send stream (not yet piped through compression/encryption)
|
|
143
|
+
*/
|
|
144
|
+
export async function createSendStream(subvolPath, { parent = null, privileged = 'noninteractive' } = {}) {
|
|
145
|
+
const args = ['btrfs', 'send'];
|
|
146
|
+
if (parent) {
|
|
147
|
+
args.push('-p', parent, subvolPath);
|
|
148
|
+
} else {
|
|
149
|
+
args.push(subvolPath);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Spawn via sudo, return the stdout stream
|
|
153
|
+
const sudoArgs = privileged === 'interactive'
|
|
154
|
+
? ['sudo', '-E', ...args]
|
|
155
|
+
: ['sudo', '-n', ...args];
|
|
156
|
+
|
|
157
|
+
const child = spawn(sudoArgs[0], sudoArgs.slice(1), {
|
|
158
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Convert child.stdout to a Node readable stream
|
|
162
|
+
return child.stdout;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Estimate the size of a BTRFS send stream.
|
|
167
|
+
* This is approximate because we can't know the exact compressed size beforehand.
|
|
168
|
+
* We use `btrfs qgroup show` or fall back to `du` on the subvolume.
|
|
169
|
+
* @param {string} subvolPath
|
|
170
|
+
* @param {object} opts - {parent: string|null}
|
|
171
|
+
* @returns {Promise<number>} - Estimated size in bytes
|
|
172
|
+
*/
|
|
173
|
+
export async function estimateSendSize(subvolPath, { parent = null } = {}) {
|
|
174
|
+
try {
|
|
175
|
+
// Try qgroup first for accurate size
|
|
176
|
+
const res = await execa('sudo', ['btrfs', 'qgroup', 'show', '-r', '--raw', subvolPath], { reject: false });
|
|
177
|
+
if (res.exitCode === 0) {
|
|
178
|
+
// Parse output: columns are like "qgroupid referenced exclusive"
|
|
179
|
+
const lines = res.stdout.trim().split('\n').slice(1); // skip header
|
|
180
|
+
if (lines.length > 0) {
|
|
181
|
+
const parts = lines[0].trim().split(/\s+/);
|
|
182
|
+
if (parts.length >= 2) {
|
|
183
|
+
const referenced = parseInt(parts[1], 10);
|
|
184
|
+
if (!isNaN(referenced)) {
|
|
185
|
+
// If there's a parent, estimate it's much smaller (just the diff)
|
|
186
|
+
// This is a rough heuristic: real diff size varies widely
|
|
187
|
+
return parent ? Math.floor(referenced * 0.1) : referenced;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} catch {}
|
|
193
|
+
|
|
194
|
+
// Fallback: use du (will be inaccurate for COW snapshots)
|
|
195
|
+
try {
|
|
196
|
+
const res = await execa('sudo', ['du', '-sb', subvolPath], { reject: false });
|
|
197
|
+
if (res.exitCode === 0) {
|
|
198
|
+
const size = parseInt(res.stdout.split('\t')[0], 10);
|
|
199
|
+
if (!isNaN(size)) {
|
|
200
|
+
return parent ? Math.floor(size * 0.1) : size;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch {}
|
|
204
|
+
|
|
205
|
+
// Ultimate fallback
|
|
206
|
+
return parent ? 100 * 1024 * 1024 : 10 * 1024 * 1024 * 1024; // 100MB for incremental, 10GB for full
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Build the full compression + encryption + upload pipeline.
|
|
211
|
+
* Returns a writable stream that accepts the raw btrfs send output.
|
|
212
|
+
* @param {ReadableStream} sendStream - The btrfs send stdout
|
|
213
|
+
* @param {object} opts - {compression, encryption, passphrase, remote, remotePath}
|
|
214
|
+
* @returns {Promise<{child: ChildProcess, promise: Promise}>}
|
|
215
|
+
*/
|
|
216
|
+
export function createUploadPipeline(sendStream, { compression = true, encryption = false, passphrase = '', remote, remotePath }) {
|
|
217
|
+
const pipeline = [];
|
|
218
|
+
|
|
219
|
+
// Stage 1: Compression (zstd)
|
|
220
|
+
if (compression) {
|
|
221
|
+
const zstd = spawn('zstd', ['-T0', '-c'], { stdio: ['pipe', 'pipe', 'inherit'] });
|
|
222
|
+
pipeline.push(zstd);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Stage 2: Encryption (openssl)
|
|
226
|
+
if (encryption && passphrase) {
|
|
227
|
+
const openssl = spawn('openssl', ['enc', '-e', '-aes256', '-pbkdf2', '-pass', `pass:${passphrase}`], {
|
|
228
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
229
|
+
});
|
|
230
|
+
pipeline.push(openssl);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Stage 3: Upload (rclone rcat)
|
|
234
|
+
const rclone = spawn(process.env.PBB_RCLONE || 'rclone', ['rcat', `${remote}:${remotePath}`], {
|
|
235
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
236
|
+
});
|
|
237
|
+
pipeline.push(rclone);
|
|
238
|
+
|
|
239
|
+
// Wire the pipeline: sendStream -> zstd -> openssl -> rclone
|
|
240
|
+
let current = sendStream;
|
|
241
|
+
for (const stage of pipeline) {
|
|
242
|
+
current.pipe(stage.stdin);
|
|
243
|
+
current = stage.stdout;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Return a promise that resolves when the final stage completes
|
|
247
|
+
const promise = new Promise((resolve, reject) => {
|
|
248
|
+
const last = pipeline[pipeline.length - 1];
|
|
249
|
+
last.on('close', (code) => {
|
|
250
|
+
if (code === 0) resolve();
|
|
251
|
+
else reject(new Error(`Pipeline failed with exit code ${code}`));
|
|
252
|
+
});
|
|
253
|
+
last.on('error', reject);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
return { child: pipeline[pipeline.length - 1], promise };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Build the reverse pipeline for restore: download -> decrypt -> decompress -> btrfs receive
|
|
261
|
+
* @param {string} remote - Remote name
|
|
262
|
+
* @param {string} remotePath - Path on the remote
|
|
263
|
+
* @param {object} opts - {encryption, passphrase, compression, receiveDir, privileged}
|
|
264
|
+
* @returns {Promise<void>}
|
|
265
|
+
*/
|
|
266
|
+
export async function createRestorePipeline({ remote, remotePath, encryption = false, passphrase = '', compression = true, receiveDir, privileged = 'noninteractive' }) {
|
|
267
|
+
const pipeline = [];
|
|
268
|
+
|
|
269
|
+
// Stage 1: Download (rclone cat)
|
|
270
|
+
const rclone = spawn(process.env.PBB_RCLONE || 'rclone', ['cat', `${remote}:${remotePath}`], {
|
|
271
|
+
stdio: ['ignore', 'pipe', 'inherit'],
|
|
272
|
+
});
|
|
273
|
+
pipeline.push(rclone);
|
|
274
|
+
|
|
275
|
+
// Stage 2: Decryption (openssl dec)
|
|
276
|
+
if (encryption && passphrase) {
|
|
277
|
+
const openssl = spawn('openssl', ['enc', '-d', '-aes256', '-pbkdf2', '-pass', `pass:${passphrase}`], {
|
|
278
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
279
|
+
});
|
|
280
|
+
pipeline.push(openssl);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Stage 3: Decompression (zstd -d)
|
|
284
|
+
if (compression) {
|
|
285
|
+
const zstd = spawn('zstd', ['-d', '-c'], { stdio: ['pipe', 'pipe', 'inherit'] });
|
|
286
|
+
pipeline.push(zstd);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Stage 4: btrfs receive (needs sudo)
|
|
290
|
+
const sudoArgs = privileged === 'interactive'
|
|
291
|
+
? ['sudo', '-E', 'btrfs', 'receive', receiveDir]
|
|
292
|
+
: ['sudo', '-n', 'btrfs', 'receive', receiveDir];
|
|
293
|
+
const receive = spawn(sudoArgs[0], sudoArgs.slice(1), {
|
|
294
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
295
|
+
});
|
|
296
|
+
pipeline.push(receive);
|
|
297
|
+
|
|
298
|
+
// Wire the pipeline
|
|
299
|
+
for (let i = 0; i < pipeline.length - 1; i++) {
|
|
300
|
+
pipeline[i].stdout.pipe(pipeline[i + 1].stdin);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Return a promise that resolves when receive completes
|
|
304
|
+
return new Promise((resolve, reject) => {
|
|
305
|
+
const last = pipeline[pipeline.length - 1];
|
|
306
|
+
last.on('close', (code) => {
|
|
307
|
+
if (code === 0) resolve();
|
|
308
|
+
else reject(new Error(`Restore pipeline failed with exit code ${code}`));
|
|
309
|
+
});
|
|
310
|
+
last.on('error', reject);
|
|
311
|
+
});
|
|
312
|
+
}
|