mirror of
https://github.com/wekan/wekan.git
synced 2026-11-07 11:02:33 -05:00
As decided on 2026-10-03: - Encryption: with Encrypt on, the built-in engines seal what they store - segments, page deltas, bases, blobs and state - with AES-256-GCM from Node's crypto. The key is a file the administrator holds outside the target and everything the stream reads, never in the database: 64 hex characters, or a passphrase stretched with scrypt and the stream's salt. stream.json keeps the salt and an HMAC check, so a wrong key is refused on save and before a restore; blob names are keyed HMACs; a generation records whether it is encrypted and is refused without its key. - Cloud upload: the target is mirrored to S3/MinIO, Azure or GCS with the Attachments storage panes' adapters and credentials. Sealed files go up once, and an index or generation file again after the files it names, so the remote copy never lists a segment it lacks; retention removals follow, a failed upload is retried, and Fetch from cloud fills a lost target. - SQLite restore on restart: a rebuilt, checked wekan.sqlite is staged in <db>/continuous-restore with RESTORE_REQUESTED "continuous". The Docker entrypoint, start-wekan.sh and the Snap keep the live database and its WAL in continuous-restore/replaced first, then copy the staged one in, before FerretDB opens its files. A pending request is never overwritten. Tests: tests/continuousBackupEncryption.test.cjs, tests/continuousBackupCloud.test.cjs, tests/continuousBackupRestartRestore.test.cjs (each script's real restore block in a sandbox), encrypted integration tests on a real replica set and WAL database, the server methods, and the pane in Chromium and WebKit. Thanks to xet7 !
114 lines
4.8 KiB
JavaScript
114 lines
4.8 KiB
JavaScript
'use strict';
|
|
|
|
// The SQLite page engine of continuous backup (docs/Backup/Continuous-Backup.md):
|
|
// at every interval a consistent image of each database file through SQLite's
|
|
// online backup API (node:sqlite, safe while FerretDB writes in WAL mode),
|
|
// compared page by page with the previous image; only the changed pages are
|
|
// stored. Restore applies the base and the deltas up to a moment, and checks
|
|
// the result with PRAGMA quick_check.
|
|
const fs = require('node:fs');
|
|
const fsp = require('node:fs/promises');
|
|
const path = require('node:path');
|
|
const crypto = require('node:crypto');
|
|
const { SegmentLog, writeAtomic, realDirectory } = require('./store');
|
|
const { PLAIN } = require('./encryption');
|
|
const { sqlitePageSize, diffImage } = require('../../../models/lib/continuousBackup');
|
|
|
|
function nodeSqlite() {
|
|
try {
|
|
const sqlite = require('node:sqlite');
|
|
return typeof sqlite.backup === 'function' ? sqlite : null;
|
|
} catch (error) { return null; }
|
|
}
|
|
|
|
// The database files of a FerretDB SQLite directory: one <db>.sqlite per
|
|
// database, without the oplog's local.sqlite, which a restore must not bring
|
|
// back (snap-src/bin/ferretdb-control resets it on purpose).
|
|
async function sqliteFiles(dir) {
|
|
if (!dir) return [];
|
|
let names;
|
|
try { names = await fsp.readdir(dir); } catch (error) { return []; }
|
|
const files = [];
|
|
for (const name of names.filter(n => /^[A-Za-z0-9_-]+\.sqlite$/.test(n) && n !== 'local.sqlite').sort()) {
|
|
const stat = await fsp.lstat(path.join(dir, name));
|
|
if (stat.isFile()) files.push(path.join(dir, name));
|
|
}
|
|
return files;
|
|
}
|
|
|
|
// A consistent copy of `file` at `out`, while it is being written.
|
|
async function consistentImage(file, out) {
|
|
const sqlite = nodeSqlite();
|
|
if (!sqlite) throw new Error('node:sqlite with backup() is not available');
|
|
const source = new sqlite.DatabaseSync(file, { readOnly: true });
|
|
try { await sqlite.backup(source, out); } finally { source.close(); }
|
|
return fsp.readFile(out);
|
|
}
|
|
|
|
class SqliteEngine {
|
|
constructor({ files, target, generation, intervalMs = 60000, onError = () => {}, onState = async () => {}, state = {}, cipher = PLAIN }) {
|
|
Object.assign(this, { files, target, generation, intervalMs, onError, onState, cipher });
|
|
this.hashes = state.hashes || {};
|
|
this.stats = { deltas: 0, pages: 0, lastChangeAt: null, lastRunAt: null };
|
|
}
|
|
static available() { return !!nodeSqlite(); }
|
|
|
|
async start() {
|
|
this.logs = {};
|
|
for (const file of this.files) {
|
|
const name = path.basename(file, '.sqlite');
|
|
const dir = await realDirectory(path.join(this.generation.dir, 'sqlite', name));
|
|
this.logs[name] = await new SegmentLog(dir, 'pages', this.cipher).open();
|
|
}
|
|
await this.run();
|
|
this.timer = setInterval(() => { this.run().catch(this.onError); }, this.intervalMs);
|
|
return this;
|
|
}
|
|
async stop() { clearInterval(this.timer); await this.running?.catch(() => {}); }
|
|
|
|
run() {
|
|
if (this.running) return this.running;
|
|
this.running = (async () => {
|
|
for (const file of this.files) await this.capture(file);
|
|
this.stats.lastRunAt = Date.now();
|
|
await this.onState({ hashes: this.hashes });
|
|
})().finally(() => { this.running = null; });
|
|
return this.running;
|
|
}
|
|
|
|
async capture(file) {
|
|
const name = path.basename(file, '.sqlite');
|
|
const dir = path.join(this.generation.dir, 'sqlite', name);
|
|
const temp = path.join(dir, `.image-${crypto.randomBytes(6).toString('hex')}`);
|
|
try {
|
|
const image = await consistentImage(file, temp);
|
|
const pageSize = sqlitePageSize(image);
|
|
const base = path.join(dir, 'base.sqlite');
|
|
if (!fs.existsSync(base)) {
|
|
// The first image is the base; its hashes are the comparison point.
|
|
await writeAtomic(base, this.cipher.encrypt(image));
|
|
this.hashes[name] = diffImage([], image, pageSize).hashes;
|
|
return;
|
|
}
|
|
const { hashes, pages, delta } = diffImage(this.hashes[name] || [], image, pageSize);
|
|
const shrank = hashes.length !== (this.hashes[name] || []).length;
|
|
if (!pages.length && !shrank) return;
|
|
await this.logs[name].writeBinary(delta, Date.now(), pages.length);
|
|
this.hashes[name] = hashes;
|
|
this.stats.deltas += 1; this.stats.pages += pages.length; this.stats.lastChangeAt = Date.now();
|
|
} finally { await fsp.rm(temp, { force: true }); }
|
|
}
|
|
|
|
status() { return { engine: 'sqlite', files: this.files.map(f => path.basename(f)), ...this.stats }; }
|
|
}
|
|
|
|
// Check a rebuilt image the way FerretDB's check-sqlite does.
|
|
function quickCheck(file) {
|
|
const sqlite = nodeSqlite();
|
|
if (!sqlite) throw new Error('node:sqlite is not available');
|
|
const db = new sqlite.DatabaseSync(file, { readOnly: true });
|
|
try { return db.prepare('PRAGMA quick_check').all().map(row => Object.values(row)[0]); } finally { db.close(); }
|
|
}
|
|
|
|
module.exports = { SqliteEngine, sqliteFiles, consistentImage, quickCheck, nodeSqlite };
|