meta-sovereign 0.49.6 → 0.49.7
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/js/src/cli/index.js +14 -1
- package/js/src/server/backup-job.js +6 -6
- package/js/src/server/routes-backup.js +7 -8
- package/js/src/storage/backup-archive.js +571 -0
- package/js/src/storage/backup.js +368 -95
- package/js/src/storage/dual-store.js +4 -0
- package/js/src/storage/durable.js +26 -1
- package/js/src/storage/secret-store.js +5 -0
- package/package.json +1 -1
package/js/src/cli/index.js
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
createBackup,
|
|
44
44
|
pruneBackups,
|
|
45
45
|
restoreBackup,
|
|
46
|
+
restoreBackupToDir,
|
|
46
47
|
} from '../storage/backup.js';
|
|
47
48
|
import { writeEncryptedExport } from '../storage/export-encrypted.js';
|
|
48
49
|
import { CURRENT_SCHEMA } from '../storage/migrations/index.js';
|
|
@@ -80,7 +81,7 @@ Commands:
|
|
|
80
81
|
import --source=<name> --file=<path> --store=<dir>
|
|
81
82
|
export --file=<path> --store=<dir>
|
|
82
83
|
backup --store=<dir> --archive=<dir> [--keep=<n>]
|
|
83
|
-
restore --file=<path> --store=<dir>
|
|
84
|
+
restore --file=<path> --store=<dir> [--replace]
|
|
84
85
|
passcode --clear [--store=<dir>] (remove a forgotten local passcode)
|
|
85
86
|
serve [--port=<n>] [--store=<dir>] [--allow-origin=<origins|none>] [--ui-url=<url>] [--local-ui=false] [--tls=false] [--quiet-logs]
|
|
86
87
|
verbose logs are written to <store>/logs/meta-sovereign.log by default
|
|
@@ -171,6 +172,18 @@ const passcodeCmd = async (args, log) => {
|
|
|
171
172
|
};
|
|
172
173
|
|
|
173
174
|
const restoreCmd = async (args, log) => {
|
|
175
|
+
if (args.replace) {
|
|
176
|
+
// Swap the whole log in one step; the store must not be in use (R-Z16).
|
|
177
|
+
const n = await restoreBackupToDir(
|
|
178
|
+
args.file,
|
|
179
|
+
args.store ?? '.meta-sovereign',
|
|
180
|
+
{
|
|
181
|
+
replace: true,
|
|
182
|
+
}
|
|
183
|
+
);
|
|
184
|
+
log(`replaced the store with ${n} records from ${args.file}`);
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
174
187
|
const store = await openStore(args.store ?? '.meta-sovereign');
|
|
175
188
|
const n = await restoreBackup(store, args.file);
|
|
176
189
|
log(`restored ${n} links from ${args.file}`);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The automatic backup, started once the port is ours (R-A4, R-Z7).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* `MS_BACKUP_INTERVAL_HOURS` (or the `backups.intervalHours` option)
|
|
6
|
-
* the interval in hours, `0`
|
|
7
|
-
* `backups.keep`) sets how many archives are kept (default
|
|
4
|
+
* ON by default (R-Z16): a streaming backup every 24 hours, the newest 3
|
|
5
|
+
* kept. `MS_BACKUP_INTERVAL_HOURS` (or the `backups.intervalHours` option)
|
|
6
|
+
* sets the interval in hours, `0` turns it off; `MS_BACKUP_KEEP` (or
|
|
7
|
+
* `backups.keep`) sets how many archives are kept (default 3, `0` keeps
|
|
8
8
|
* all). A value that is not a number >= 0 is ignored for the default and
|
|
9
9
|
* logged once.
|
|
10
10
|
* The archive is encrypted, and holds the secrets, only when the server has
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
import { createBackupScheduler } from '../storage/backup.js';
|
|
16
16
|
|
|
17
17
|
const HOUR_MS = 3_600_000;
|
|
18
|
-
export const DEFAULT_INTERVAL_HOURS =
|
|
19
|
-
export const DEFAULT_KEEP =
|
|
18
|
+
export const DEFAULT_INTERVAL_HOURS = 24;
|
|
19
|
+
export const DEFAULT_KEEP = 3;
|
|
20
20
|
|
|
21
21
|
// A number >= 0, or the fallback; `bad` collects the names that were wrong.
|
|
22
22
|
const number = (value, fallback, name, bad) => {
|
|
@@ -18,17 +18,16 @@ import { promises as fs } from 'node:fs';
|
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
import { json, readBody } from './util.js';
|
|
20
20
|
import { safeMessage } from './redact.js';
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
isBackupFile,
|
|
23
|
+
makeBackup,
|
|
24
|
+
restoreBackup,
|
|
25
|
+
pruneBackups,
|
|
26
|
+
} from '../storage/backup.js';
|
|
22
27
|
|
|
23
28
|
const listFiles = async (archiveDir) => {
|
|
24
29
|
const entries = await fs.readdir(archiveDir).catch(() => []);
|
|
25
|
-
const ours = entries
|
|
26
|
-
.filter(
|
|
27
|
-
(f) =>
|
|
28
|
-
f.startsWith('meta-sovereign-') &&
|
|
29
|
-
(f.endsWith('.json') || f.endsWith('.json.enc'))
|
|
30
|
-
)
|
|
31
|
-
.sort();
|
|
30
|
+
const ours = entries.filter(isBackupFile).sort();
|
|
32
31
|
const out = [];
|
|
33
32
|
for (const f of ours) {
|
|
34
33
|
const stat = await fs.stat(path.join(archiveDir, f)).catch(() => null);
|
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The streaming backup archive, format 2 (R-Z16).
|
|
3
|
+
*
|
|
4
|
+
* A real store is gigabytes: the old archive (one JSON string holding every
|
|
5
|
+
* link) cannot even be built past V8's string limit. Format 2 is read and
|
|
6
|
+
* written a piece at a time, so memory stays flat whatever the store size.
|
|
7
|
+
*
|
|
8
|
+
* Plain content (what is gzipped), line by line:
|
|
9
|
+
*
|
|
10
|
+
* {"format":"meta-sovereign-backup","version":2,"createdAt":...,"secrets":...}
|
|
11
|
+
* <one Links Notation record per line, the store's log as it was>
|
|
12
|
+
* #end {"lines":N,"secretsExcluded":K,"bytes":B,"sha256":"<of every line above>"}
|
|
13
|
+
*
|
|
14
|
+
* File on disk: a 28-byte header, then frames of the gzip stream:
|
|
15
|
+
*
|
|
16
|
+
* header = "MSBK2\n" | mode (0 plain, 1 AES-256-GCM) | 0 | salt(16) | nonce prefix(4)
|
|
17
|
+
* frame = final flag (1) | length (4, BE) | data (length) | tag (16)
|
|
18
|
+
*
|
|
19
|
+
* Every frame (up to 1 MB) is sealed on its own: AES-256-GCM with the
|
|
20
|
+
* nonce `prefix | frame index`, or (no passphrase) a SHA-256 check tag. The
|
|
21
|
+
* additional data is `header | index | final flag`, so a flipped byte, a
|
|
22
|
+
* reordered or dropped frame, and a file cut short are all detected, and
|
|
23
|
+
* a frame is checked before any of it is used. The key is scrypt of the
|
|
24
|
+
* passphrase and the salt, as before.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { createReadStream, promises as fs } from 'node:fs';
|
|
28
|
+
import { createGunzip, createGzip } from 'node:zlib';
|
|
29
|
+
import { Readable } from 'node:stream';
|
|
30
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
31
|
+
import {
|
|
32
|
+
createCipheriv,
|
|
33
|
+
createDecipheriv,
|
|
34
|
+
createHash,
|
|
35
|
+
randomBytes,
|
|
36
|
+
scrypt,
|
|
37
|
+
} from 'node:crypto';
|
|
38
|
+
import { decodeLine } from 'lino-objects-codec';
|
|
39
|
+
|
|
40
|
+
export const FRAME_BYTES = 1024 * 1024;
|
|
41
|
+
const MAGIC = Buffer.from('MSBK2\n');
|
|
42
|
+
const HEADER_BYTES = 28;
|
|
43
|
+
const TAG_BYTES = 16;
|
|
44
|
+
const KDF_N = 1 << 14;
|
|
45
|
+
export const FORMAT = 'meta-sovereign-backup';
|
|
46
|
+
const TRAILER = '#end ';
|
|
47
|
+
|
|
48
|
+
/** An error a person can act on, with a stable `code`. */
|
|
49
|
+
export const archiveError = (code, message) =>
|
|
50
|
+
Object.assign(new Error(message), { code });
|
|
51
|
+
|
|
52
|
+
const deriveKey = (passphrase, salt) =>
|
|
53
|
+
new Promise((resolve, reject) =>
|
|
54
|
+
scrypt(passphrase, salt, 32, { N: KDF_N }, (error, key) =>
|
|
55
|
+
error ? reject(error) : resolve(key)
|
|
56
|
+
)
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const aadOf = (header, index, final) => {
|
|
60
|
+
const tail = Buffer.alloc(9);
|
|
61
|
+
tail.writeBigUInt64BE(BigInt(index), 0);
|
|
62
|
+
tail[8] = final ? 1 : 0;
|
|
63
|
+
return Buffer.concat([header, tail]);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const nonceOf = (header, index) => {
|
|
67
|
+
const nonce = Buffer.alloc(12);
|
|
68
|
+
header.copy(nonce, 0, 24, 28);
|
|
69
|
+
nonce.writeBigUInt64BE(BigInt(index), 4);
|
|
70
|
+
return nonce;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// The check tag of a plain frame: it catches damage, not a forger (nobody
|
|
74
|
+
// can promise that without a key).
|
|
75
|
+
const plainTag = (aad, data) =>
|
|
76
|
+
createHash('sha256').update(aad).update(data).digest().subarray(0, TAG_BYTES);
|
|
77
|
+
|
|
78
|
+
const sealFrame = ({ header, key }, index, final, data) => {
|
|
79
|
+
const aad = aadOf(header, index, final);
|
|
80
|
+
let body = data;
|
|
81
|
+
let tag;
|
|
82
|
+
if (key) {
|
|
83
|
+
const cipher = createCipheriv('aes-256-gcm', key, nonceOf(header, index));
|
|
84
|
+
cipher.setAAD(aad);
|
|
85
|
+
body = Buffer.concat([cipher.update(data), cipher.final()]);
|
|
86
|
+
tag = cipher.getAuthTag();
|
|
87
|
+
} else {
|
|
88
|
+
tag = plainTag(aad, data);
|
|
89
|
+
}
|
|
90
|
+
const head = Buffer.alloc(5);
|
|
91
|
+
head[0] = final ? 1 : 0;
|
|
92
|
+
head.writeUInt32BE(body.length, 1);
|
|
93
|
+
return Buffer.concat([head, body, tag]);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** A pipeline stage: gzip chunks in, sealed frames (header first) out. */
|
|
97
|
+
export const sealStage = (passphrase) =>
|
|
98
|
+
async function* seal(source) {
|
|
99
|
+
const salt = passphrase ? randomBytes(16) : Buffer.alloc(16);
|
|
100
|
+
const header = Buffer.concat([
|
|
101
|
+
MAGIC,
|
|
102
|
+
Buffer.from([passphrase ? 1 : 0, 0]),
|
|
103
|
+
salt,
|
|
104
|
+
randomBytes(4),
|
|
105
|
+
]);
|
|
106
|
+
const key = passphrase ? await deriveKey(passphrase, salt) : null;
|
|
107
|
+
const ctx = { header, key };
|
|
108
|
+
yield header;
|
|
109
|
+
let index = 0;
|
|
110
|
+
let pending = [];
|
|
111
|
+
let size = 0;
|
|
112
|
+
for await (const chunk of source) {
|
|
113
|
+
pending.push(chunk);
|
|
114
|
+
size += chunk.length;
|
|
115
|
+
if (size > FRAME_BYTES) {
|
|
116
|
+
const all = Buffer.concat(pending);
|
|
117
|
+
let at = 0;
|
|
118
|
+
while (all.length - at > FRAME_BYTES) {
|
|
119
|
+
yield sealFrame(
|
|
120
|
+
ctx,
|
|
121
|
+
index,
|
|
122
|
+
false,
|
|
123
|
+
all.subarray(at, at + FRAME_BYTES)
|
|
124
|
+
);
|
|
125
|
+
index += 1;
|
|
126
|
+
at += FRAME_BYTES;
|
|
127
|
+
}
|
|
128
|
+
pending = [all.subarray(at)];
|
|
129
|
+
size = all.length - at;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// The last frame is always sealed as such, even when empty.
|
|
133
|
+
yield sealFrame(ctx, index, true, Buffer.concat(pending));
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// A reader that hands out exact byte counts from a stream of chunks.
|
|
137
|
+
const createTaker = (source) => {
|
|
138
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
139
|
+
let list = [];
|
|
140
|
+
let have = 0;
|
|
141
|
+
let done = false;
|
|
142
|
+
return async (count) => {
|
|
143
|
+
while (have < count && !done) {
|
|
144
|
+
const next = await iterator.next();
|
|
145
|
+
if (next.done) {
|
|
146
|
+
done = true;
|
|
147
|
+
} else {
|
|
148
|
+
list.push(next.value);
|
|
149
|
+
have += next.value.length;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (have < count) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const all = Buffer.concat(list);
|
|
156
|
+
list = all.length > count ? [all.subarray(count)] : [];
|
|
157
|
+
have = all.length - count;
|
|
158
|
+
return all.subarray(0, count);
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const truncated = () =>
|
|
163
|
+
archiveError(
|
|
164
|
+
'BACKUP_TRUNCATED',
|
|
165
|
+
'the backup file is cut short (incomplete write or copy)'
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const openFrame = ({ header, key }, index, frame, tag) => {
|
|
169
|
+
const final = frame.final;
|
|
170
|
+
const aad = aadOf(header, index, final);
|
|
171
|
+
if (key) {
|
|
172
|
+
const decipher = createDecipheriv(
|
|
173
|
+
'aes-256-gcm',
|
|
174
|
+
key,
|
|
175
|
+
nonceOf(header, index)
|
|
176
|
+
);
|
|
177
|
+
decipher.setAAD(aad);
|
|
178
|
+
decipher.setAuthTag(tag);
|
|
179
|
+
return Buffer.concat([decipher.update(frame.data), decipher.final()]);
|
|
180
|
+
}
|
|
181
|
+
if (!plainTag(aad, frame.data).equals(tag)) {
|
|
182
|
+
throw new Error('frame check failed');
|
|
183
|
+
}
|
|
184
|
+
return frame.data;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const readHeader = async (take, passphrase) => {
|
|
188
|
+
const header = await take(HEADER_BYTES);
|
|
189
|
+
if (!header || !header.subarray(0, MAGIC.length).equals(MAGIC)) {
|
|
190
|
+
throw archiveError('BACKUP_INVALID', 'not a meta-sovereign backup file');
|
|
191
|
+
}
|
|
192
|
+
const encrypted = header[6] === 1;
|
|
193
|
+
if (encrypted && !passphrase) {
|
|
194
|
+
throw archiveError(
|
|
195
|
+
'BACKUP_PASSPHRASE_REQUIRED',
|
|
196
|
+
'encrypted backup requires a passphrase'
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
const key = encrypted
|
|
200
|
+
? await deriveKey(passphrase, header.subarray(8, 24))
|
|
201
|
+
: null;
|
|
202
|
+
return { header, key, encrypted };
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// A frame that does not open: the first of an encrypted file is most
|
|
206
|
+
// likely a wrong passphrase, any other a changed or reordered file.
|
|
207
|
+
const failedFrame = (index, encrypted) =>
|
|
208
|
+
index === 0 && encrypted
|
|
209
|
+
? archiveError(
|
|
210
|
+
'BACKUP_BAD_PASSPHRASE',
|
|
211
|
+
'wrong passphrase, or the backup file is damaged'
|
|
212
|
+
)
|
|
213
|
+
: archiveError(
|
|
214
|
+
'BACKUP_TAMPERED',
|
|
215
|
+
`the backup file was changed or reordered (frame ${index})`
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The gzip bytes of an archive file, frame by frame, each checked before
|
|
220
|
+
* it is handed on. Throws `BACKUP_*` errors: PASSPHRASE_REQUIRED, INVALID
|
|
221
|
+
* (not an archive), TRUNCATED, BAD_PASSPHRASE (the first frame does not
|
|
222
|
+
* open), TAMPERED (a later frame does not).
|
|
223
|
+
*/
|
|
224
|
+
export async function* unframe(source, { passphrase = null } = {}) {
|
|
225
|
+
const take = createTaker(source);
|
|
226
|
+
const { header, key, encrypted } = await readHeader(take, passphrase);
|
|
227
|
+
for (let index = 0; ; index += 1) {
|
|
228
|
+
const head = await take(5);
|
|
229
|
+
const length = head?.readUInt32BE(1);
|
|
230
|
+
if (length > FRAME_BYTES) {
|
|
231
|
+
throw archiveError('BACKUP_INVALID', 'the backup file is damaged');
|
|
232
|
+
}
|
|
233
|
+
const body = head && (await take(length + TAG_BYTES));
|
|
234
|
+
if (!body) {
|
|
235
|
+
throw truncated();
|
|
236
|
+
}
|
|
237
|
+
const frame = { final: head[0] === 1, data: body.subarray(0, length) };
|
|
238
|
+
let data;
|
|
239
|
+
try {
|
|
240
|
+
data = openFrame({ header, key }, index, frame, body.subarray(length));
|
|
241
|
+
} catch {
|
|
242
|
+
throw failedFrame(index, encrypted);
|
|
243
|
+
}
|
|
244
|
+
yield data;
|
|
245
|
+
if (frame.final) {
|
|
246
|
+
if (await take(1)) {
|
|
247
|
+
throw archiveError(
|
|
248
|
+
'BACKUP_INVALID',
|
|
249
|
+
'data after the end of the backup'
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// '\n'-separated lines of a byte stream (a line is never split by a chunk).
|
|
258
|
+
async function* linesOf(source) {
|
|
259
|
+
const decoder = new StringDecoder('utf8');
|
|
260
|
+
let rest = '';
|
|
261
|
+
for await (const chunk of source) {
|
|
262
|
+
rest += typeof chunk === 'string' ? chunk : decoder.write(chunk);
|
|
263
|
+
let at = rest.indexOf('\n');
|
|
264
|
+
while (at !== -1) {
|
|
265
|
+
yield rest.slice(0, at);
|
|
266
|
+
rest = rest.slice(at + 1);
|
|
267
|
+
at = rest.indexOf('\n');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
rest += decoder.end();
|
|
271
|
+
if (rest.length > 0) {
|
|
272
|
+
yield rest;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export const isRecordLine = (line) => line.startsWith('(');
|
|
277
|
+
|
|
278
|
+
const parseManifest = (line) => {
|
|
279
|
+
let manifest;
|
|
280
|
+
try {
|
|
281
|
+
manifest = JSON.parse(line);
|
|
282
|
+
} catch {
|
|
283
|
+
manifest = null;
|
|
284
|
+
}
|
|
285
|
+
if (manifest?.format !== FORMAT || manifest.version !== 2) {
|
|
286
|
+
throw archiveError('BACKUP_INVALID', 'not a meta-sovereign backup file');
|
|
287
|
+
}
|
|
288
|
+
return manifest;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const parseTrailer = (line) => {
|
|
292
|
+
try {
|
|
293
|
+
return JSON.parse(line.slice(TRAILER.length));
|
|
294
|
+
} catch {
|
|
295
|
+
throw archiveError('BACKUP_INVALID', 'the backup trailer is unreadable');
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const checkTrailer = ({ manifest, trailer, lines }, sha256) => {
|
|
300
|
+
if (!manifest || !trailer) {
|
|
301
|
+
throw truncated();
|
|
302
|
+
}
|
|
303
|
+
if (trailer.sha256 !== sha256 || trailer.lines !== lines) {
|
|
304
|
+
throw archiveError(
|
|
305
|
+
'BACKUP_INVALID',
|
|
306
|
+
'the backup content does not match its trailer'
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
// Sort one plain line into the manifest, the trailer or a record; true for
|
|
312
|
+
// a record (which the caller hears).
|
|
313
|
+
const takeLine = (seen, line) => {
|
|
314
|
+
if (seen.trailer) {
|
|
315
|
+
throw archiveError('BACKUP_INVALID', 'data after the backup trailer');
|
|
316
|
+
}
|
|
317
|
+
if (!seen.manifest) {
|
|
318
|
+
seen.manifest = parseManifest(line);
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
if (line.startsWith(TRAILER)) {
|
|
322
|
+
seen.trailer = parseTrailer(line);
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
if (line.length === 0) {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
seen.lines += 1;
|
|
329
|
+
return true;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Read a whole archive, checking everything: every frame, the manifest,
|
|
334
|
+
* the trailer's line count and SHA-256. `onLine(line)` hears each record
|
|
335
|
+
* line (awaited, so it can write them somewhere). Resolves to
|
|
336
|
+
* `{ manifest, trailer, lines, sha256 }`; nothing is trusted before it
|
|
337
|
+
* does, so a caller that applies lines as they come should scan once first.
|
|
338
|
+
*/
|
|
339
|
+
export const scanArchive = async (file, { passphrase, onLine } = {}) => {
|
|
340
|
+
const hash = createHash('sha256');
|
|
341
|
+
const seen = { manifest: null, trailer: null, lines: 0 };
|
|
342
|
+
let fromCaller = null; // an error of `onLine` is the caller's, not damage
|
|
343
|
+
const hear = async (line) => {
|
|
344
|
+
try {
|
|
345
|
+
await onLine?.(line);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
fromCaller = error;
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
const gunzip = createGunzip();
|
|
352
|
+
const text = Readable.from(unframe(createReadStream(file), { passphrase }));
|
|
353
|
+
const plain = text.pipe(gunzip);
|
|
354
|
+
text.on('error', (error) => gunzip.destroy(error));
|
|
355
|
+
try {
|
|
356
|
+
for await (const line of linesOf(plain)) {
|
|
357
|
+
if (takeLine(seen, line)) {
|
|
358
|
+
await hear(line);
|
|
359
|
+
}
|
|
360
|
+
if (!seen.trailer) {
|
|
361
|
+
hash.update(`${line}\n`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
} catch (error) {
|
|
365
|
+
text.destroy();
|
|
366
|
+
if (error === fromCaller || error.code?.startsWith?.('BACKUP_')) {
|
|
367
|
+
throw error;
|
|
368
|
+
}
|
|
369
|
+
throw archiveError('BACKUP_TRUNCATED', 'the backup file is damaged');
|
|
370
|
+
}
|
|
371
|
+
const sha256 = hash.digest('hex');
|
|
372
|
+
const { manifest, trailer, lines } = seen;
|
|
373
|
+
checkTrailer(seen, sha256);
|
|
374
|
+
return { manifest, trailer, lines, sha256 };
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The cheap check (no passphrase, no decompression): walk the frame
|
|
379
|
+
* headers; the file must end exactly on a frame marked final. Catches a
|
|
380
|
+
* file cut short; a flipped byte needs `scanArchive`.
|
|
381
|
+
*/
|
|
382
|
+
export const quickCheckArchive = async (file) => {
|
|
383
|
+
const handle = await fs.open(file, 'r');
|
|
384
|
+
try {
|
|
385
|
+
const { size } = await handle.stat();
|
|
386
|
+
const head = Buffer.alloc(HEADER_BYTES);
|
|
387
|
+
const read = await handle.read(head, 0, HEADER_BYTES, 0);
|
|
388
|
+
if (read.bytesRead < HEADER_BYTES || !head.subarray(0, 6).equals(MAGIC)) {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
let at = HEADER_BYTES;
|
|
392
|
+
const frame = Buffer.alloc(5);
|
|
393
|
+
for (;;) {
|
|
394
|
+
if (at + 5 > size) {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
await handle.read(frame, 0, 5, at);
|
|
398
|
+
at += 5 + frame.readUInt32BE(1) + TAG_BYTES;
|
|
399
|
+
if (at > size) {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
if (frame[0] === 1) {
|
|
403
|
+
return at === size;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
} finally {
|
|
407
|
+
await handle.close();
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
/** Whether `bytes` start an archive of format 2. */
|
|
412
|
+
export const isArchiveFormat2 = async (file) => {
|
|
413
|
+
const handle = await fs.open(file, 'r');
|
|
414
|
+
try {
|
|
415
|
+
const head = Buffer.alloc(MAGIC.length);
|
|
416
|
+
await handle.read(head, 0, MAGIC.length, 0);
|
|
417
|
+
return head.equals(MAGIC);
|
|
418
|
+
} finally {
|
|
419
|
+
await handle.close();
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// The last newline in the first `size` bytes of the log: where a snapshot
|
|
424
|
+
// of the log taken now ends. A torn last append (no newline) is left out.
|
|
425
|
+
export const lastNewlineEnd = async (handle, size) => {
|
|
426
|
+
const block = Buffer.alloc(64 * 1024);
|
|
427
|
+
let end = size;
|
|
428
|
+
while (end > 0) {
|
|
429
|
+
const start = Math.max(0, end - block.length);
|
|
430
|
+
const { bytesRead } = await handle.read(block, 0, end - start, start);
|
|
431
|
+
const at = block.subarray(0, bytesRead).lastIndexOf(0x0a);
|
|
432
|
+
if (at !== -1) {
|
|
433
|
+
return start + at + 1;
|
|
434
|
+
}
|
|
435
|
+
end = start;
|
|
436
|
+
}
|
|
437
|
+
return 0;
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
const idOf = (line) => {
|
|
441
|
+
try {
|
|
442
|
+
return decodeLine({ notation: line })?.id;
|
|
443
|
+
} catch {
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
export const isSecretLine = (line) =>
|
|
449
|
+
line.includes('secret:') &&
|
|
450
|
+
typeof idOf(line) === 'string' &&
|
|
451
|
+
idOf(line).startsWith('secret:');
|
|
452
|
+
|
|
453
|
+
// Raw record lines of the store's log up to its length now (appends that
|
|
454
|
+
// land during the backup are after that point and are not read).
|
|
455
|
+
export async function* logLines(store, onError) {
|
|
456
|
+
await store.flushLog?.();
|
|
457
|
+
const handle = await fs.open(store.logFile, 'r').catch((error) => {
|
|
458
|
+
if (error.code === 'ENOENT') {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
throw error;
|
|
462
|
+
});
|
|
463
|
+
if (!handle) {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
try {
|
|
467
|
+
const { size } = await handle.stat();
|
|
468
|
+
const end = await lastNewlineEnd(handle, size);
|
|
469
|
+
if (end === 0) {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const input = handle.createReadStream({
|
|
473
|
+
start: 0,
|
|
474
|
+
end: end - 1,
|
|
475
|
+
autoClose: false,
|
|
476
|
+
});
|
|
477
|
+
for await (const line of linesOf(input)) {
|
|
478
|
+
if (isRecordLine(line)) {
|
|
479
|
+
yield line;
|
|
480
|
+
} else if (line.trim().length > 0) {
|
|
481
|
+
onError?.(line); // legacy or damaged text: not archived
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
} finally {
|
|
485
|
+
await handle.close();
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Encoded links of a store that has no log file (memory stores, tests).
|
|
490
|
+
async function* queriedLines(store, encodeLine) {
|
|
491
|
+
const links = await store.query();
|
|
492
|
+
for (const link of links) {
|
|
493
|
+
yield encodeLine({ obj: link });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* The plain content of an archive as an async stream of strings, and the
|
|
499
|
+
* running `stats` ({ lines, secretsExcluded, skipped, bytes, sha256 }; the
|
|
500
|
+
* hash is final once the stream ended). Secrets are filtered while
|
|
501
|
+
* streaming unless `includeSecrets`.
|
|
502
|
+
*/
|
|
503
|
+
export const archiveContent = ({
|
|
504
|
+
store,
|
|
505
|
+
createdAt,
|
|
506
|
+
includeSecrets,
|
|
507
|
+
encodeLine,
|
|
508
|
+
onProgress,
|
|
509
|
+
schema = null,
|
|
510
|
+
}) => {
|
|
511
|
+
const stats = {
|
|
512
|
+
lines: 0,
|
|
513
|
+
secretsExcluded: 0,
|
|
514
|
+
skipped: 0,
|
|
515
|
+
bytes: 0,
|
|
516
|
+
sha256: null,
|
|
517
|
+
};
|
|
518
|
+
const hash = createHash('sha256');
|
|
519
|
+
const emit = (text) => {
|
|
520
|
+
hash.update(text);
|
|
521
|
+
stats.bytes += Buffer.byteLength(text);
|
|
522
|
+
return text;
|
|
523
|
+
};
|
|
524
|
+
async function* content() {
|
|
525
|
+
yield emit(
|
|
526
|
+
`${JSON.stringify({
|
|
527
|
+
format: FORMAT,
|
|
528
|
+
version: 2,
|
|
529
|
+
createdAt,
|
|
530
|
+
secrets: includeSecrets ? 'included-encrypted' : 'excluded',
|
|
531
|
+
// The data schema the records are in (R-Z17).
|
|
532
|
+
schema: schema === null ? undefined : { version: schema },
|
|
533
|
+
source: store.logFile ? 'log' : 'links',
|
|
534
|
+
})}\n`
|
|
535
|
+
);
|
|
536
|
+
const lines = store.logFile
|
|
537
|
+
? logLines(store, () => (stats.skipped += 1))
|
|
538
|
+
: queriedLines(store, encodeLine);
|
|
539
|
+
let batch = '';
|
|
540
|
+
for await (const line of lines) {
|
|
541
|
+
if (!includeSecrets && isSecretLine(line)) {
|
|
542
|
+
stats.secretsExcluded += 1;
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
stats.lines += 1;
|
|
546
|
+
batch += `${line}\n`;
|
|
547
|
+
if (batch.length >= 256 * 1024) {
|
|
548
|
+
yield emit(batch);
|
|
549
|
+
batch = '';
|
|
550
|
+
onProgress?.(stats);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (batch) {
|
|
554
|
+
yield emit(batch);
|
|
555
|
+
}
|
|
556
|
+
stats.sha256 = hash.digest('hex');
|
|
557
|
+
yield `${TRAILER}${JSON.stringify({
|
|
558
|
+
lines: stats.lines,
|
|
559
|
+
secretsExcluded: stats.secretsExcluded,
|
|
560
|
+
bytes: stats.bytes,
|
|
561
|
+
sha256: stats.sha256,
|
|
562
|
+
})}\n`;
|
|
563
|
+
}
|
|
564
|
+
return { stream: Readable.from(content(), { objectMode: false }), stats };
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
/** The stages from content to sealed frames: `pipeline(content, ...stages, sink)`. */
|
|
568
|
+
export const archiveStages = (passphrase) => [
|
|
569
|
+
createGzip({ level: 4 }),
|
|
570
|
+
sealStage(passphrase),
|
|
571
|
+
];
|
package/js/src/storage/backup.js
CHANGED
|
@@ -1,33 +1,57 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Backup helper — snapshots a
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Backup helper — snapshots a store into a single timestamped file in the
|
|
3
|
+
* configured archive folder, and applies a simple keep-last-N retention
|
|
4
|
+
* policy (R-A4).
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Archives are written and read as streams (format 2, `backup-archive.js`,
|
|
7
|
+
* R-Z16): the store's log is gzipped line by line and sealed in 1 MB
|
|
8
|
+
* authenticated frames (AES-256-GCM with a passphrase), so a multi-gigabyte
|
|
9
|
+
* store backs up and restores in flat memory. `data.bin` is not archived: it
|
|
10
|
+
* is a snapshot of the log and is rebuilt from it when the store opens.
|
|
11
|
+
* Archives of the earlier format (one JSON document, `.json`/`.json.enc`)
|
|
12
|
+
* still verify and restore.
|
|
10
13
|
*/
|
|
11
14
|
|
|
12
15
|
import { promises as fs } from 'node:fs';
|
|
13
16
|
import path from 'node:path';
|
|
14
17
|
import { setInterval, clearInterval } from 'node:timers';
|
|
18
|
+
import { PassThrough } from 'node:stream';
|
|
19
|
+
import { once } from 'node:events';
|
|
15
20
|
import {
|
|
16
21
|
randomBytes,
|
|
17
22
|
scryptSync,
|
|
18
23
|
createCipheriv,
|
|
19
24
|
createDecipheriv,
|
|
20
25
|
} from 'node:crypto';
|
|
26
|
+
import { decodeLine, encodeLine } from 'lino-objects-codec';
|
|
21
27
|
import { createMemoryStore, putAll } from './universal.js';
|
|
22
|
-
import { ensurePrivateDir, sweepTemp, writeFileDurable } from './durable.js';
|
|
23
|
-
import { enoughSpace, skippedEvent } from './disk-space.js';
|
|
24
28
|
import {
|
|
25
29
|
CURRENT_SCHEMA,
|
|
26
30
|
MIGRATIONS,
|
|
27
31
|
SCHEMA_ID,
|
|
28
32
|
runMigrations,
|
|
33
|
+
schemaLink,
|
|
29
34
|
versionOf,
|
|
30
35
|
} from './migrations/index.js';
|
|
36
|
+
import {
|
|
37
|
+
ensurePrivateDir,
|
|
38
|
+
fsyncDir,
|
|
39
|
+
tighten,
|
|
40
|
+
sweepTemp,
|
|
41
|
+
writeStreamDurable,
|
|
42
|
+
} from './durable.js';
|
|
43
|
+
import { enoughSpace, skippedEvent } from './disk-space.js';
|
|
44
|
+
import { acquireStoreLock } from './store-lock.js';
|
|
45
|
+
import {
|
|
46
|
+
archiveContent,
|
|
47
|
+
archiveError,
|
|
48
|
+
archiveStages,
|
|
49
|
+
isArchiveFormat2,
|
|
50
|
+
isSecretLine,
|
|
51
|
+
logLines,
|
|
52
|
+
quickCheckArchive,
|
|
53
|
+
scanArchive,
|
|
54
|
+
} from './backup-archive.js';
|
|
31
55
|
|
|
32
56
|
const ENC_VERSION = 1;
|
|
33
57
|
const KDF_N = 1 << 14;
|
|
@@ -36,10 +60,9 @@ const deriveKey = (passphrase, salt) =>
|
|
|
36
60
|
scryptSync(passphrase, salt, 32, { N: KDF_N });
|
|
37
61
|
|
|
38
62
|
/**
|
|
39
|
-
* Encrypt a
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* sites move to tar.zst (R-A4 hardening).
|
|
63
|
+
* Encrypt a small text with AES-256-GCM. The output envelope is JSON with
|
|
64
|
+
* base64 fields. (Used for single secrets and exports; backups of a whole
|
|
65
|
+
* store are framed, see `backup-archive.js`.)
|
|
43
66
|
*/
|
|
44
67
|
export const encryptBackup = (plaintext, passphrase) => {
|
|
45
68
|
const salt = randomBytes(16);
|
|
@@ -80,45 +103,93 @@ export const decryptBackup = (envelopeJson, passphrase) => {
|
|
|
80
103
|
const stamp = (d = new Date()) =>
|
|
81
104
|
d.toISOString().replace(/[:.]/g, '-').replace('T', '_').replace(/Z$/, '');
|
|
82
105
|
|
|
83
|
-
|
|
84
|
-
|
|
106
|
+
/** Whether `name` is one of our archives (format 2 `.msbk`, or the old `.json`). */
|
|
107
|
+
export const isBackupFile = (name) =>
|
|
108
|
+
/^meta-sovereign-.*\.(json|msbk)(\.enc)?$/.test(name);
|
|
85
109
|
|
|
86
|
-
|
|
110
|
+
// A second backup into one folder at the same time fails fast.
|
|
111
|
+
const running = new Set();
|
|
112
|
+
|
|
113
|
+
const looksWhole = (raw, file, passphrase) => {
|
|
114
|
+
if (!file.endsWith('.enc')) {
|
|
115
|
+
return Array.isArray(JSON.parse(raw)?.links);
|
|
116
|
+
}
|
|
117
|
+
const env = JSON.parse(raw);
|
|
118
|
+
const whole = ['salt', 'iv', 'tag', 'ct'].every(
|
|
119
|
+
(k) => typeof env[k] === 'string' && env[k].length > 0
|
|
120
|
+
);
|
|
121
|
+
if (!whole || env.cipher !== 'aes-256-gcm') {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
return passphrase
|
|
125
|
+
? Array.isArray(JSON.parse(decryptBackup(raw, passphrase))?.links)
|
|
126
|
+
: true;
|
|
127
|
+
};
|
|
87
128
|
|
|
88
129
|
/**
|
|
89
|
-
* Whether the archive `file` is complete (R-Z2)
|
|
90
|
-
*
|
|
91
|
-
* passphrase
|
|
130
|
+
* Whether the archive `file` is complete (R-Z2, R-Z16). A format 2 archive
|
|
131
|
+
* is checked cheaply by default (its frames must end on the final one; no
|
|
132
|
+
* passphrase needed); `full: true` reads and checks every frame, the
|
|
133
|
+
* manifest, the line count and the SHA-256 (needs the passphrase of an
|
|
134
|
+
* encrypted one). An old-format archive parses and holds a links array.
|
|
135
|
+
* Never throws.
|
|
92
136
|
*/
|
|
93
|
-
export const verifyBackup = async (
|
|
137
|
+
export const verifyBackup = async (
|
|
138
|
+
file,
|
|
139
|
+
{ passphrase = null, full = false } = {}
|
|
140
|
+
) => {
|
|
94
141
|
try {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
(k) => typeof env[k] === 'string' && env[k].length > 0
|
|
102
|
-
);
|
|
103
|
-
if (!whole || env.cipher !== 'aes-256-gcm') {
|
|
104
|
-
return false;
|
|
142
|
+
if (await isArchiveFormat2(file)) {
|
|
143
|
+
if (!full) {
|
|
144
|
+
return await quickCheckArchive(file);
|
|
145
|
+
}
|
|
146
|
+
await scanArchive(file, { passphrase });
|
|
147
|
+
return true;
|
|
105
148
|
}
|
|
106
|
-
return passphrase
|
|
107
|
-
? Array.isArray(JSON.parse(decryptBackup(raw, passphrase))?.links)
|
|
108
|
-
: true;
|
|
149
|
+
return looksWhole(await fs.readFile(file, 'utf8'), file, passphrase);
|
|
109
150
|
} catch {
|
|
110
151
|
return false;
|
|
111
152
|
}
|
|
112
153
|
};
|
|
113
154
|
|
|
155
|
+
// Free space a backup of `store` needs: gzip takes the text to about a
|
|
156
|
+
// third; a store without a log file is estimated from its link count.
|
|
157
|
+
const estimateBytes = async (store, passphrase) => {
|
|
158
|
+
const text = store.logFile
|
|
159
|
+
? ((await fs.stat(store.logFile).catch(() => null))?.size ?? 0)
|
|
160
|
+
: (await store.query()).length * 1024;
|
|
161
|
+
return Math.ceil(text * 0.35 * (passphrase ? 1.02 : 1)) + 4096;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// Read the new archive back whole and compare it with what was written.
|
|
165
|
+
const confirmWritten = async (file, passphrase, stats) => {
|
|
166
|
+
try {
|
|
167
|
+
const read = await scanArchive(file, { passphrase });
|
|
168
|
+
if (read.sha256 !== stats.sha256 || read.lines !== stats.lines) {
|
|
169
|
+
throw new Error('content differs');
|
|
170
|
+
}
|
|
171
|
+
} catch (cause) {
|
|
172
|
+
await fs.unlink(file).catch(() => {});
|
|
173
|
+
throw Object.assign(new Error('the backup could not be read back'), {
|
|
174
|
+
code: 'BACKUP_VERIFY_FAILED',
|
|
175
|
+
cause,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
114
180
|
/**
|
|
115
181
|
* Write a backup and say what it holds: `{ file, links, secrets,
|
|
116
|
-
* secretsExcluded
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
182
|
+
* secretsExcluded, bytes, sha256 }` (`links` counts the log records
|
|
183
|
+
* archived). The log is streamed up to the length it had when the backup
|
|
184
|
+
* began, cut back to its last whole line, so writes landing meanwhile
|
|
185
|
+
* cannot tear it. The archive is written atomically (temp file, fsync,
|
|
186
|
+
* rename, fsync of the folder), 0600, then read back whole and compared
|
|
187
|
+
* with what was written; one that does not verify is removed and the call
|
|
188
|
+
* fails. Secrets (`secret:*` links) go in only when the archive is
|
|
189
|
+
* encrypted; otherwise they are filtered out while streaming and the
|
|
190
|
+
* manifest counts them (R-Z2). On a nearly full disk nothing is written
|
|
191
|
+
* and the answer is `{ skipped: true, reason: 'low_disk', needed, free }`
|
|
192
|
+
* (R-Z14).
|
|
122
193
|
*/
|
|
123
194
|
export const makeBackup = async (
|
|
124
195
|
store,
|
|
@@ -127,51 +198,52 @@ export const makeBackup = async (
|
|
|
127
198
|
now = new Date(),
|
|
128
199
|
passphrase = null,
|
|
129
200
|
onEvent = () => {},
|
|
201
|
+
onProgress,
|
|
130
202
|
measureFree,
|
|
131
203
|
}
|
|
132
204
|
) => {
|
|
133
205
|
await ensurePrivateDir(archiveDir);
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
version: 1,
|
|
141
|
-
secrets: passphrase ? 'included-encrypted' : 'excluded',
|
|
142
|
-
secretsExcluded,
|
|
143
|
-
// The data schema the links are in (R-Z17); a manifest without it is version 0.
|
|
144
|
-
schema: { version: schemaVersion },
|
|
145
|
-
links,
|
|
146
|
-
};
|
|
147
|
-
const plaintext = JSON.stringify(manifest, null, 2);
|
|
148
|
-
// The archive is about as large as its text (a third more once encrypted
|
|
149
|
-
// and base64'd); on a nearly full disk it is skipped, not half written
|
|
150
|
-
// (R-Z14).
|
|
151
|
-
const estimate = Math.ceil(plaintext.length * (passphrase ? 1.4 : 1));
|
|
152
|
-
const space = await enoughSpace(archiveDir, estimate, measureFree);
|
|
206
|
+
await tighten(archiveDir, 0o700);
|
|
207
|
+
const space = await enoughSpace(
|
|
208
|
+
archiveDir,
|
|
209
|
+
await estimateBytes(store, passphrase),
|
|
210
|
+
measureFree
|
|
211
|
+
);
|
|
153
212
|
if (!space.ok) {
|
|
154
213
|
onEvent(skippedEvent('backup_skipped', space));
|
|
155
214
|
return { skipped: true, reason: 'low_disk', ...space, links: 0 };
|
|
156
215
|
}
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
216
|
+
const folder = path.resolve(archiveDir);
|
|
217
|
+
if (running.has(folder)) {
|
|
218
|
+
throw archiveError('BACKUP_BUSY', 'a backup is already running');
|
|
219
|
+
}
|
|
220
|
+
running.add(folder);
|
|
221
|
+
const file = path.join(
|
|
222
|
+
archiveDir,
|
|
223
|
+
`meta-sovereign-${stamp(now)}.msbk${passphrase ? '.enc' : ''}`
|
|
162
224
|
);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
225
|
+
try {
|
|
226
|
+
const { stream, stats } = archiveContent({
|
|
227
|
+
store,
|
|
228
|
+
createdAt: now.toISOString(),
|
|
229
|
+
includeSecrets: Boolean(passphrase),
|
|
230
|
+
schema: store.get ? versionOf(await store.get(SCHEMA_ID)) : null,
|
|
231
|
+
encodeLine,
|
|
232
|
+
onProgress,
|
|
167
233
|
});
|
|
234
|
+
await writeStreamDurable(file, stream, ...archiveStages(passphrase));
|
|
235
|
+
await confirmWritten(file, passphrase, stats);
|
|
236
|
+
return {
|
|
237
|
+
file,
|
|
238
|
+
links: stats.lines,
|
|
239
|
+
secrets: passphrase ? 'included-encrypted' : 'excluded',
|
|
240
|
+
secretsExcluded: stats.secretsExcluded,
|
|
241
|
+
bytes: (await fs.stat(file)).size,
|
|
242
|
+
sha256: stats.sha256,
|
|
243
|
+
};
|
|
244
|
+
} finally {
|
|
245
|
+
running.delete(folder);
|
|
168
246
|
}
|
|
169
|
-
return {
|
|
170
|
-
file,
|
|
171
|
-
links: links.length,
|
|
172
|
-
secrets: manifest.secrets,
|
|
173
|
-
secretsExcluded,
|
|
174
|
-
};
|
|
175
247
|
};
|
|
176
248
|
|
|
177
249
|
export const createBackup = async (store, options) => {
|
|
@@ -188,12 +260,13 @@ export const createBackup = async (store, options) => {
|
|
|
188
260
|
* Keep the newest `keep` archives. Nothing is deleted unless the newest
|
|
189
261
|
* archive verifies as complete (a crash or a full disk may have left a
|
|
190
262
|
* truncated one that must not cost the older good ones), and leftover
|
|
191
|
-
* temp files of a crashed write are swept (R-Z2).
|
|
263
|
+
* temp files of a crashed write are swept (R-Z2). The check is the cheap
|
|
264
|
+
* one (frame structure; the full read happened when it was written).
|
|
192
265
|
*/
|
|
193
266
|
export const pruneBackups = async ({ archiveDir, keep, passphrase = null }) => {
|
|
194
267
|
await sweepTemp(archiveDir);
|
|
195
268
|
const entries = await fs.readdir(archiveDir).catch(() => []);
|
|
196
|
-
const ours = entries.filter(
|
|
269
|
+
const ours = entries.filter(isBackupFile).sort();
|
|
197
270
|
const toDelete = ours.slice(0, Math.max(0, ours.length - keep));
|
|
198
271
|
if (keep < 1 || toDelete.length === 0) {
|
|
199
272
|
return [];
|
|
@@ -272,12 +345,20 @@ export const createBackupScheduler = ({
|
|
|
272
345
|
};
|
|
273
346
|
};
|
|
274
347
|
|
|
348
|
+
const tooNew = (version, current) =>
|
|
349
|
+
Object.assign(
|
|
350
|
+
new Error(
|
|
351
|
+
`this backup was written by a newer version (schema ${version}, this app understands up to ${current}); update the app`
|
|
352
|
+
),
|
|
353
|
+
{ code: 'BACKUP_TOO_NEW', found: version, current }
|
|
354
|
+
);
|
|
355
|
+
|
|
275
356
|
/**
|
|
276
|
-
* The links of
|
|
277
|
-
* schema is migrated in memory, before anything is written to
|
|
278
|
-
* a failing migration leaves the store untouched; one of a
|
|
279
|
-
* refused with BACKUP_TOO_NEW. The backup's own
|
|
280
|
-
* restored (the store keeps its own).
|
|
357
|
+
* The links of an old-format backup in the current shapes (R-Z17). A backup
|
|
358
|
+
* of an older schema is migrated in memory, before anything is written to
|
|
359
|
+
* the store, so a failing migration leaves the store untouched; one of a
|
|
360
|
+
* newer schema is refused with BACKUP_TOO_NEW. The backup's own
|
|
361
|
+
* `meta:schema` is never restored (the store keeps its own).
|
|
281
362
|
*/
|
|
282
363
|
export const linksOfManifest = async (
|
|
283
364
|
manifest,
|
|
@@ -287,12 +368,7 @@ export const linksOfManifest = async (
|
|
|
287
368
|
? manifest.schema.version
|
|
288
369
|
: 0;
|
|
289
370
|
if (version > current) {
|
|
290
|
-
throw
|
|
291
|
-
new Error(
|
|
292
|
-
`this backup was written by a newer version (schema ${version}, this app understands up to ${current}); update the app`
|
|
293
|
-
),
|
|
294
|
-
{ code: 'BACKUP_TOO_NEW', found: version, current }
|
|
295
|
-
);
|
|
371
|
+
throw tooNew(version, current);
|
|
296
372
|
}
|
|
297
373
|
const links = manifest.links.filter((l) => l.id !== SCHEMA_ID);
|
|
298
374
|
if (version === current) {
|
|
@@ -321,11 +397,90 @@ export const linksOfManifest = async (
|
|
|
321
397
|
return (await scratch.query()).filter((l) => l.id !== SCHEMA_ID);
|
|
322
398
|
};
|
|
323
399
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
400
|
+
// The schema a format 2 archive was written in; refuses a newer one.
|
|
401
|
+
const archiveSchema = (manifest) => {
|
|
402
|
+
const version = manifest?.schema?.version;
|
|
403
|
+
if (!Number.isInteger(version)) {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
if (version > CURRENT_SCHEMA) {
|
|
407
|
+
throw tooNew(version, CURRENT_SCHEMA);
|
|
408
|
+
}
|
|
409
|
+
return version;
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
// Lower the store's schema stamp to the archive's, so the records restored
|
|
413
|
+
// in older shapes are migrated when the store next opens (R-Z17).
|
|
414
|
+
const stampOlder = async (store, version) => {
|
|
415
|
+
if (version === null || version >= CURRENT_SCHEMA) {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const existing = await store.get(SCHEMA_ID);
|
|
419
|
+
if (versionOf(existing) > version) {
|
|
420
|
+
await store.put({
|
|
421
|
+
...schemaLink({ version, applied: existing?.applied ?? [] }),
|
|
422
|
+
app: existing?.app ?? null,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const TOMBSTONE = '~deleted';
|
|
428
|
+
const isTombstone = (record) =>
|
|
429
|
+
record?.[TOMBSTONE] === true && Object.keys(record).length === 2;
|
|
430
|
+
|
|
431
|
+
const BATCH = 500;
|
|
432
|
+
|
|
433
|
+
const malformed = () =>
|
|
434
|
+
archiveError('BACKUP_INVALID', 'not a valid backup: a record is malformed');
|
|
435
|
+
|
|
436
|
+
const recordOfLine = (line) => {
|
|
437
|
+
let record;
|
|
438
|
+
try {
|
|
439
|
+
record = decodeLine({ notation: line });
|
|
440
|
+
} catch {
|
|
441
|
+
throw malformed();
|
|
442
|
+
}
|
|
443
|
+
if (typeof record?.id !== 'string' || !record.id) {
|
|
444
|
+
throw malformed();
|
|
445
|
+
}
|
|
446
|
+
return record;
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// Replay the archive's records into `target`, a batch at a time.
|
|
450
|
+
const applyArchive = async (target, file, passphrase) => {
|
|
451
|
+
let batch = [];
|
|
452
|
+
let restored = 0;
|
|
453
|
+
const flush = async () => {
|
|
454
|
+
if (batch.length > 0) {
|
|
455
|
+
await putAll(target, batch);
|
|
456
|
+
restored += batch.length;
|
|
457
|
+
batch = [];
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
await scanArchive(file, {
|
|
461
|
+
passphrase,
|
|
462
|
+
onLine: async (line) => {
|
|
463
|
+
const record = recordOfLine(line);
|
|
464
|
+
if (record.id === SCHEMA_ID) {
|
|
465
|
+
return; // the store keeps its own stamp (see stampOlder)
|
|
466
|
+
}
|
|
467
|
+
if (isTombstone(record)) {
|
|
468
|
+
await flush();
|
|
469
|
+
await target.delete(record.id);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
batch.push(record);
|
|
473
|
+
if (batch.length >= BATCH) {
|
|
474
|
+
await flush();
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
await flush();
|
|
479
|
+
return restored;
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
// The old format: one JSON document, checked whole before anything is written.
|
|
483
|
+
const restoreLegacy = async (store, file, passphrase, onEvent) => {
|
|
329
484
|
let raw = await fs.readFile(file, 'utf8');
|
|
330
485
|
if (file.endsWith('.enc')) {
|
|
331
486
|
if (!passphrase) {
|
|
@@ -334,14 +489,132 @@ export const restoreBackup = async (
|
|
|
334
489
|
raw = decryptBackup(raw, passphrase);
|
|
335
490
|
}
|
|
336
491
|
const manifest = JSON.parse(raw);
|
|
337
|
-
// Checked whole before anything is written: no half restore (R-Z2).
|
|
338
492
|
const valid =
|
|
339
493
|
Array.isArray(manifest?.links) &&
|
|
340
494
|
manifest.links.every((l) => l && typeof l === 'object' && l.id);
|
|
341
495
|
if (!valid) {
|
|
342
|
-
throw
|
|
343
|
-
|
|
344
|
-
|
|
496
|
+
throw archiveError(
|
|
497
|
+
'BACKUP_INVALID',
|
|
498
|
+
'not a valid backup: links are malformed'
|
|
499
|
+
);
|
|
345
500
|
}
|
|
346
501
|
return putAll(store, await linksOfManifest(manifest, { onEvent }));
|
|
347
502
|
};
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Put the archive's links into the open `store` and say how many (R-Z16).
|
|
506
|
+
* The whole archive is read and checked first (every frame, the manifest,
|
|
507
|
+
* the SHA-256, every record), so a damaged or tampered file changes
|
|
508
|
+
* nothing; then its records are replayed in order, 500 at a time (a later
|
|
509
|
+
* record replaces an earlier one, a tombstone deletes). Replaying is
|
|
510
|
+
* idempotent: an interrupted restore is finished by running it again. The
|
|
511
|
+
* store is the caller's open one, which holds the folder's lock, so
|
|
512
|
+
* another process cannot write to the folder meanwhile (R-Z2); to replace
|
|
513
|
+
* the store of a folder that no process has open, use `restoreBackupToDir`.
|
|
514
|
+
*/
|
|
515
|
+
export const restoreBackup = async (
|
|
516
|
+
store,
|
|
517
|
+
file,
|
|
518
|
+
{ passphrase = null, onEvent = () => {} } = {}
|
|
519
|
+
) => {
|
|
520
|
+
if (!(await isArchiveFormat2(file))) {
|
|
521
|
+
return restoreLegacy(store, file, passphrase, onEvent);
|
|
522
|
+
}
|
|
523
|
+
const { manifest } = await scanArchive(file, {
|
|
524
|
+
passphrase,
|
|
525
|
+
onLine: (line) => {
|
|
526
|
+
recordOfLine(line);
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
const version = archiveSchema(manifest);
|
|
530
|
+
// The secret wrapper would seal the log's already sealed secrets again.
|
|
531
|
+
const restored = await applyArchive(
|
|
532
|
+
store.rawStore ?? store,
|
|
533
|
+
file,
|
|
534
|
+
passphrase
|
|
535
|
+
);
|
|
536
|
+
await stampOlder(store.rawStore ?? store, version);
|
|
537
|
+
return restored;
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
// Write `line` to `out`, waiting when its buffer is full.
|
|
541
|
+
const emitLine = async (out, line) => {
|
|
542
|
+
if (!out.write(`${line}\n`)) {
|
|
543
|
+
await once(out, 'drain');
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
// Feed `out` the archive's records, then (for a plain archive, which holds
|
|
548
|
+
// no secrets) the secrets of the log being replaced; end it when whole.
|
|
549
|
+
const feedRestored = async (out, file, { passphrase, oldLog }) => {
|
|
550
|
+
try {
|
|
551
|
+
const { manifest } = await scanArchive(file, {
|
|
552
|
+
passphrase,
|
|
553
|
+
onLine: (line) => emitLine(out, line),
|
|
554
|
+
});
|
|
555
|
+
if (oldLog && manifest.secrets === 'excluded') {
|
|
556
|
+
for await (const line of logLines({ logFile: oldLog })) {
|
|
557
|
+
if (isSecretLine(line)) {
|
|
558
|
+
await emitLine(out, line);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
out.end();
|
|
563
|
+
} catch (error) {
|
|
564
|
+
out.destroy(error);
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Restore an archive as the store of the folder `dir` (R-Z16): the
|
|
570
|
+
* archive is checked whole, then written to a temp file, fsynced and
|
|
571
|
+
* renamed over `data.lino` in one step, so the folder holds the old store
|
|
572
|
+
* or the whole new one, never half. Refuses (`STORE_LOCKED`) when another
|
|
573
|
+
* process has the folder open, and (`BACKUP_TARGET_NOT_EMPTY`) when it
|
|
574
|
+
* already holds a store unless `replace: true`; the replaced log is kept
|
|
575
|
+
* beside it as `data.lino.before-restore-<time>`. A plain archive has no
|
|
576
|
+
* secrets, so the folder's own `secret:*` lines are carried over. `data.bin`
|
|
577
|
+
* is removed: it is rebuilt from the log when the store opens. Resolves to
|
|
578
|
+
* the number of records restored.
|
|
579
|
+
*/
|
|
580
|
+
export const restoreBackupToDir = async (
|
|
581
|
+
file,
|
|
582
|
+
dir,
|
|
583
|
+
{ passphrase = null, replace = false } = {}
|
|
584
|
+
) => {
|
|
585
|
+
if (!(await isArchiveFormat2(file))) {
|
|
586
|
+
throw archiveError(
|
|
587
|
+
'BACKUP_INVALID',
|
|
588
|
+
'only format 2 backups restore into a folder'
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
const checked = await scanArchive(file, { passphrase });
|
|
592
|
+
archiveSchema(checked.manifest);
|
|
593
|
+
await ensurePrivateDir(dir);
|
|
594
|
+
const lock = await acquireStoreLock(dir);
|
|
595
|
+
try {
|
|
596
|
+
const log = path.join(dir, 'data.lino');
|
|
597
|
+
const old = await fs.stat(log).catch(() => null);
|
|
598
|
+
if (old && old.size > 0 && !replace) {
|
|
599
|
+
throw archiveError(
|
|
600
|
+
'BACKUP_TARGET_NOT_EMPTY',
|
|
601
|
+
'the folder already holds a store (use replace)'
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
if (old) {
|
|
605
|
+
await fs.link(log, `${log}.before-restore-${stamp()}`);
|
|
606
|
+
}
|
|
607
|
+
const out = new PassThrough();
|
|
608
|
+
const feeding = feedRestored(out, file, {
|
|
609
|
+
passphrase,
|
|
610
|
+
oldLog: old ? log : null,
|
|
611
|
+
});
|
|
612
|
+
await writeStreamDurable(log, out);
|
|
613
|
+
await feeding;
|
|
614
|
+
await fs.rm(path.join(dir, 'data.bin'), { force: true });
|
|
615
|
+
await fsyncDir(dir);
|
|
616
|
+
return checked.lines;
|
|
617
|
+
} finally {
|
|
618
|
+
lock.release();
|
|
619
|
+
}
|
|
620
|
+
};
|
|
@@ -130,6 +130,7 @@ const readOnlyStore = (text, schema) => ({
|
|
|
130
130
|
corruptLines: text.corruptLines,
|
|
131
131
|
health: () => ({ degraded: false, queuedBytes: 0, error: null }),
|
|
132
132
|
readOnly: true,
|
|
133
|
+
logFile: text.filePath, // streamed by a backup (R-Z16)
|
|
133
134
|
});
|
|
134
135
|
|
|
135
136
|
// A folder and everything below it (the downloaded pictures and files).
|
|
@@ -235,6 +236,9 @@ export const openDualStore = async (
|
|
|
235
236
|
schema,
|
|
236
237
|
migration: schema.migrated,
|
|
237
238
|
corruptLines: text.corruptLines,
|
|
239
|
+
// What a backup streams, and the flush of just the log (R-Z16).
|
|
240
|
+
logFile: text.filePath,
|
|
241
|
+
flushLog: () => text.flush(),
|
|
238
242
|
close: lock.release,
|
|
239
243
|
};
|
|
240
244
|
} catch (error) {
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* user: 0600, directories 0700 (a no-op on Windows).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { promises as fs } from 'node:fs';
|
|
12
|
+
import { createWriteStream, promises as fs } from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
14
|
+
import { pipeline } from 'node:stream/promises';
|
|
14
15
|
|
|
15
16
|
export const FILE_MODE = 0o600;
|
|
16
17
|
export const DIR_MODE = 0o700;
|
|
@@ -99,6 +100,30 @@ export const writeFileDurable = async (
|
|
|
99
100
|
await fsyncDir(dir);
|
|
100
101
|
};
|
|
101
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Like `writeFileDurable`, but the content is a pipeline of streams
|
|
105
|
+
* (`source, ...stages`) written as it is produced, so a multi-gigabyte
|
|
106
|
+
* file never exists in memory (R-Z16). Temp file at 0600, fsync, rename,
|
|
107
|
+
* fsync of the folder; the temp file goes at once when anything fails.
|
|
108
|
+
*/
|
|
109
|
+
export const writeStreamDurable = async (file, ...streams) => {
|
|
110
|
+
const dir = path.dirname(file);
|
|
111
|
+
await ensurePrivateDir(dir);
|
|
112
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
113
|
+
try {
|
|
114
|
+
await pipeline(
|
|
115
|
+
...streams,
|
|
116
|
+
createWriteStream(tmp, { flags: 'w', mode: FILE_MODE })
|
|
117
|
+
);
|
|
118
|
+
await fsyncFile(tmp);
|
|
119
|
+
await fs.rename(tmp, file);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
await fs.unlink(tmp).catch(() => {});
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
await fsyncDir(dir);
|
|
125
|
+
};
|
|
126
|
+
|
|
102
127
|
/** Remove `*.tmp` leftovers of a crashed write, older than a minute. */
|
|
103
128
|
export const sweepTemp = async (dir, olderThanMs = 60_000) => {
|
|
104
129
|
const removed = [];
|
|
@@ -56,6 +56,11 @@ export const wrapSecretStore = ({ inner, passphrase }) => {
|
|
|
56
56
|
return inner;
|
|
57
57
|
}
|
|
58
58
|
return {
|
|
59
|
+
// A backup streams the log as stored (secrets sealed) and a restore
|
|
60
|
+
// puts them back unsealed-again-free (R-Z16).
|
|
61
|
+
rawStore: inner,
|
|
62
|
+
logFile: inner.logFile,
|
|
63
|
+
flushLog: inner.flushLog,
|
|
59
64
|
async put(link) {
|
|
60
65
|
if (isSecret(link)) {
|
|
61
66
|
const sealed = sealLink(link, passphrase);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "meta-sovereign",
|
|
3
|
-
"version": "0.49.
|
|
3
|
+
"version": "0.49.7",
|
|
4
4
|
"description": "Personal meta profile sovereign system - fully local, privacy-respecting personal CRM and unified messenger across services",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "js/src/index.js",
|