parrot-blackbox 2.0.5 → 2.0.6
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/package.json +1 -1
- package/src/backup/snapshot.js +61 -6
- package/src/cli.js +17 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parrot-blackbox",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.6",
|
|
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",
|
package/src/backup/snapshot.js
CHANGED
|
@@ -259,6 +259,31 @@ export async function deleteAllSnapshots({ privileged = 'interactive', onProgres
|
|
|
259
259
|
|
|
260
260
|
return { deleted, failed };
|
|
261
261
|
}
|
|
262
|
+
/**
|
|
263
|
+
* A module-level registry of all temporary BTRFS mounts created during this
|
|
264
|
+
* process lifetime, so we can unmount them all at exit even if the snapshot
|
|
265
|
+
* object that originally held the reference was garbage-collected.
|
|
266
|
+
*/
|
|
267
|
+
const _activeTempMounts = new Set();
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Unmount and remove all temporary BTRFS mount points created by this process.
|
|
271
|
+
* Called on process exit and also by cleanupSnapshotMount.
|
|
272
|
+
*/
|
|
273
|
+
function _cleanupAllTempMounts() {
|
|
274
|
+
for (const mp of _activeTempMounts) {
|
|
275
|
+
try { sudoExecSync(['umount', mp]); } catch { /* best effort */ }
|
|
276
|
+
try { sudoExecSync(['rmdir', mp]); } catch { /* best effort */ }
|
|
277
|
+
_activeTempMounts.delete(mp);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Register a process exit handler so stale mounts are always cleaned up,
|
|
282
|
+
// even if the snapshot object holding _tempMount was discarded before cleanup.
|
|
283
|
+
process.on('exit', _cleanupAllTempMounts);
|
|
284
|
+
// Also handle abnormal exits so the mount isn't left behind after Ctrl+C
|
|
285
|
+
// (SIGINT/SIGTERM handlers in cli.js call process.exit(), which fires 'exit').
|
|
286
|
+
|
|
262
287
|
/**
|
|
263
288
|
* Resolve the on-disk directory of a snapshot.
|
|
264
289
|
*
|
|
@@ -270,6 +295,11 @@ export async function deleteAllSnapshots({ privileged = 'interactive', onProgres
|
|
|
270
295
|
* 2. Check static paths (rsync mode)
|
|
271
296
|
* 3. Mount the BTRFS root subvolume ourselves to access snapshots persistently
|
|
272
297
|
* 4. Fall back to triggering timeshift --list
|
|
298
|
+
*
|
|
299
|
+
* IMPORTANT: the mount point is registered in _activeTempMounts so that
|
|
300
|
+
* _cleanupAllTempMounts() can release it on process exit even if the snapshot
|
|
301
|
+
* object is later discarded (e.g. after a resume-upload path that builds a new
|
|
302
|
+
* snapshot object from just the name).
|
|
273
303
|
*/
|
|
274
304
|
export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {}) {
|
|
275
305
|
if (snapshot.dir && fs.existsSync(snapshot.dir)) return snapshot.dir;
|
|
@@ -301,6 +331,9 @@ export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {})
|
|
|
301
331
|
const mountResult = sudoExecSync(['mount', '-o', 'subvolid=5', device, mountPoint]);
|
|
302
332
|
|
|
303
333
|
if (mountResult.exitCode === 0) {
|
|
334
|
+
// Register in the global set so it is always cleaned up on exit.
|
|
335
|
+
_activeTempMounts.add(mountPoint);
|
|
336
|
+
|
|
304
337
|
// Search for the snapshot in the mounted root subvolume
|
|
305
338
|
const searchPaths = [
|
|
306
339
|
`${mountPoint}/timeshift-btrfs/snapshots/${snapshot.name}`,
|
|
@@ -311,12 +344,13 @@ export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {})
|
|
|
311
344
|
const found = searchPaths.find((p) => fs.existsSync(p));
|
|
312
345
|
|
|
313
346
|
if (found) {
|
|
314
|
-
// Store the mount point for cleanup
|
|
347
|
+
// Store the mount point on the snapshot object for explicit early cleanup.
|
|
315
348
|
snapshot._tempMount = mountPoint;
|
|
316
349
|
return found;
|
|
317
350
|
}
|
|
318
351
|
|
|
319
|
-
// Unmount if we didn't find anything
|
|
352
|
+
// Unmount immediately if we didn't find anything useful.
|
|
353
|
+
_activeTempMounts.delete(mountPoint);
|
|
320
354
|
sudoExecSync(['umount', mountPoint]);
|
|
321
355
|
sudoExecSync(['rmdir', mountPoint]);
|
|
322
356
|
}
|
|
@@ -332,13 +366,15 @@ export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {})
|
|
|
332
366
|
/** Cleanup temporary BTRFS mount if one was created */
|
|
333
367
|
export function cleanupSnapshotMount(snapshot) {
|
|
334
368
|
if (snapshot && snapshot._tempMount) {
|
|
369
|
+
const mp = snapshot._tempMount;
|
|
335
370
|
try {
|
|
336
|
-
sudoExecSync(['umount',
|
|
337
|
-
sudoExecSync(['rmdir',
|
|
338
|
-
delete snapshot._tempMount;
|
|
371
|
+
sudoExecSync(['umount', mp]);
|
|
372
|
+
sudoExecSync(['rmdir', mp]);
|
|
339
373
|
} catch {
|
|
340
374
|
/* best effort */
|
|
341
375
|
}
|
|
376
|
+
_activeTempMounts.delete(mp);
|
|
377
|
+
delete snapshot._tempMount;
|
|
342
378
|
}
|
|
343
379
|
}
|
|
344
380
|
|
|
@@ -523,11 +559,30 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
|
|
|
523
559
|
* `btrfs send` always receives a real subvolume, never a plain directory.
|
|
524
560
|
*/
|
|
525
561
|
async function uploadViaBtrfsSend({ snapshot, subvolPath, parentSnapshot, parentSubvolPath, accounts, cfg, due, privileged, onProgress }) {
|
|
526
|
-
const { createSendStream, estimateSendSize, isValidBtrfsStreamManifest } = await import('./btrfs-send.js');
|
|
562
|
+
const { createSendStream, estimateSendSize, isValidBtrfsStreamManifest, isSubvolumeReadOnly, setSubvolumeReadOnly } = await import('./btrfs-send.js');
|
|
527
563
|
const { planAndPlaceStream } = await import('../storage/allocator.js');
|
|
528
564
|
|
|
529
565
|
if (!subvolPath) throw new Error('no BTRFS subvolume path for snapshot upload');
|
|
530
566
|
|
|
567
|
+
// ── RO guard ──────────────────────────────────────────────────────────────
|
|
568
|
+
// btrfs send requires the subvolume to be read-only (ro=true). Timeshift
|
|
569
|
+
// always creates snapshots as RO, but when they are accessed via a
|
|
570
|
+
// subvolid=5 bind-mount the kernel may report the inner @ subvolume as
|
|
571
|
+
// rw=false (writable) depending on mount flags. Detect and fix this before
|
|
572
|
+
// handing the path to btrfs send, otherwise the send exits 1 with
|
|
573
|
+
// "subvolume … is not read-only".
|
|
574
|
+
const isRO = await isSubvolumeReadOnly(subvolPath, { privileged });
|
|
575
|
+
if (!isRO) {
|
|
576
|
+
journal('snapshots', `subvolume ${subvolPath} is not read-only — setting ro=true before send`);
|
|
577
|
+
try {
|
|
578
|
+
await setSubvolumeReadOnly(subvolPath, true, { privileged });
|
|
579
|
+
journal('snapshots', `set ${subvolPath} read-only OK`);
|
|
580
|
+
} catch (e) {
|
|
581
|
+
throw new Error(`btrfs send requires a read-only subvolume but ${subvolPath} is writable and could not be set read-only: ${e.message}`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
585
|
+
|
|
531
586
|
journal('snapshots', `btrfs send subvolPath=${subvolPath} parent=${parentSubvolPath || 'null'}`);
|
|
532
587
|
|
|
533
588
|
// Estimate size for progress reporting
|
package/src/cli.js
CHANGED
|
@@ -2,6 +2,8 @@ import { defineCommand, runMain } from 'citty';
|
|
|
2
2
|
import pc from 'picocolors';
|
|
3
3
|
import * as p from '@clack/prompts';
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
5
7
|
import { runSetup } from './commands/setup.js';
|
|
6
8
|
import { runWizard } from './commands/wizard.js';
|
|
7
9
|
import { runRepair } from './commands/manage.js';
|
|
@@ -667,7 +669,7 @@ const main = defineCommand({
|
|
|
667
669
|
});
|
|
668
670
|
}
|
|
669
671
|
s.stop('✔ Upload complete');
|
|
670
|
-
|
|
672
|
+
fs.writeFileSync(outPath, JSON.stringify(manifest));
|
|
671
673
|
process.exitCode = 0;
|
|
672
674
|
} catch (e) {
|
|
673
675
|
s.stop('✖ Upload failed');
|
|
@@ -677,16 +679,12 @@ const main = defineCommand({
|
|
|
677
679
|
// If we are running as root via sudo, rclone might have refreshed OAuth tokens
|
|
678
680
|
// and rewritten rclone.conf as root:root. Restore ownership to the real user.
|
|
679
681
|
if (process.getuid && process.getuid() === 0 && process.env.SUDO_UID && process.env.SUDO_GID) {
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
} catch { /* best effort */ }
|
|
687
|
-
}
|
|
688
|
-
});
|
|
689
|
-
});
|
|
682
|
+
const confPath = path.join(process.env.HOME, '.config', 'rclone', 'rclone.conf');
|
|
683
|
+
if (fs.existsSync(confPath)) {
|
|
684
|
+
try {
|
|
685
|
+
fs.chownSync(confPath, parseInt(process.env.SUDO_UID, 10), parseInt(process.env.SUDO_GID, 10));
|
|
686
|
+
} catch { /* best effort */ }
|
|
687
|
+
}
|
|
690
688
|
}
|
|
691
689
|
}
|
|
692
690
|
return;
|
|
@@ -702,4 +700,11 @@ const main = defineCommand({
|
|
|
702
700
|
process.exitCode = 1;
|
|
703
701
|
}
|
|
704
702
|
},
|
|
705
|
-
});
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
runMain(main).then(() => {
|
|
706
|
+
// Force the process to exit cleanly regardless of any open file descriptors or
|
|
707
|
+
// kernel mounts left by subvolid=5 bind-mounts created in snapshotDirFor.
|
|
708
|
+
// process.on('exit') in snapshot.js will unmount them before we terminate.
|
|
709
|
+
process.exit(process.exitCode ?? 0);
|
|
710
|
+
});
|