Files
Simo 46f7ad6571
CI / check (push) Successful in 4m12s
CI / deploy (push) Failing after 2m8s
CI / publish-container (push) Skipped
feat(ops): add integrity-checked backups and isolated restore drills
2026-09-13 20:29:18 +02:00

317 lines
8.5 KiB
JavaScript

import { createHash } from "node:crypto";
import { constants } from "node:fs";
import {
lstat,
mkdir,
open,
readdir,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import path from "node:path";
const requiredRoots = ["storage", "nitro", "swf"];
const allowedRoots = [...requiredRoots, "gamedata"];
const fail = () => {
throw Error("Backup file validation failed");
};
const sortedEntries = (entries) =>
[...entries].sort((left, right) =>
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
);
const inside = (parent, child) => {
const relative = path.relative(parent, child);
return (
!relative ||
(!relative.startsWith(`..${path.sep}`) &&
relative !== ".." &&
!path.isAbsolute(relative))
);
};
export async function safeDirectory(value) {
if (
typeof value !== "string" ||
!path.isAbsolute(value) ||
value.split(/[\\/]/).includes("..")
)
fail();
const resolved = path.resolve(value);
if (resolved === path.parse(resolved).root) fail();
for (
let current = resolved;
current !== path.dirname(current);
current = path.dirname(current)
) {
const stat = await lstat(current);
if (!stat.isDirectory() || stat.isSymbolicLink()) fail();
}
return resolved;
}
function validEntry(entry, roots) {
const name = entry.path;
if (
typeof name !== "string" ||
/[\\:]/.test(name) ||
[...name].some((character) => character.charCodeAt(0) < 32) ||
name.split("/").some((part) => !part || part === "." || part === "..")
)
fail();
if (
name !== "database.sql" &&
name !== "files" &&
!roots.some(
(root) => name === `files/${root}` || name.startsWith(`files/${root}/`),
)
)
fail();
if (!["file", "directory"].includes(entry.type)) fail();
if (
entry.type === "file" &&
(!Number.isSafeInteger(entry.bytes) ||
entry.bytes < 0 ||
!/^[a-f0-9]{64}$/.test(entry.sha256))
)
fail();
}
async function transfer(source, target) {
const before = await lstat(source);
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) fail();
const input = await open(
source,
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
);
let output;
try {
const opened = await input.stat();
if (opened.dev !== before.dev || opened.ino !== before.ino) fail();
if (target) output = await open(target, "wx", 0o600);
const hash = createHash("sha256");
let bytes = 0;
for await (const chunk of input.createReadStream({ autoClose: false })) {
hash.update(chunk);
bytes += chunk.length;
if (output) await output.writeFile(chunk);
}
const after = await input.stat();
if (
before.size !== bytes ||
after.size !== before.size ||
after.mtimeMs !== before.mtimeMs ||
after.ctimeMs !== before.ctimeMs
)
fail();
return { bytes, sha256: hash.digest("hex") };
} finally {
await input.close();
await output?.close();
}
}
async function walk(directory, prefix, destination, rejectSecrets = false) {
await safeDirectory(directory);
const entries = [{ path: prefix, type: "directory" }];
for (const name of (await readdir(directory)).sort()) {
if (
rejectSecrets &&
(/^\.env(?:\.|$)/i.test(name) ||
[".docker-install", "persistent.path", "backup.config.json"].includes(
name,
))
)
fail();
const source = path.join(directory, name);
const relative = `${prefix}/${name}`;
const stat = await lstat(source);
if (stat.isSymbolicLink()) fail();
if (stat.isDirectory()) {
if (destination)
await mkdir(path.join(destination, name), { mode: 0o700 });
entries.push(
...(await walk(
source,
relative,
destination && path.join(destination, name),
rejectSecrets,
)),
);
} else {
const content = await transfer(
source,
destination && path.join(destination, name),
);
entries.push({ path: relative, type: "file", ...content });
}
}
return entries;
}
export async function createArtifact({ roots, output }, writeDatabase) {
if (
!roots ||
requiredRoots.some((key) => !roots[key]) ||
Object.keys(roots).some((key) => !allowedRoots.includes(key))
)
fail();
const sources = await Promise.all(Object.values(roots).map(safeDirectory));
if (!path.isAbsolute(output) || output.split(/[\\/]/).includes("..")) fail();
output = path.join(
await safeDirectory(path.dirname(output)),
path.basename(output),
);
for (let index = 0; index < sources.length; index++) {
if (
inside(sources[index], output) ||
inside(output, sources[index]) ||
sources.some(
(source, other) =>
other !== index &&
(inside(source, sources[index]) || inside(sources[index], source)),
)
)
fail();
}
await mkdir(output, { mode: 0o700 }); // Exclusive reservation; never replace an existing artifact.
try {
await mkdir(path.join(output, "files"), { mode: 0o700 });
const entries = [{ path: "files", type: "directory" }];
for (const key of Object.keys(roots).sort()) {
const destination = path.join(output, "files", key);
await mkdir(destination, { mode: 0o700 });
entries.push(
...(await walk(roots[key], `files/${key}`, destination, true)),
);
}
const database = await writeDatabase(path.join(output, "database.sql"));
const sql = await transfer(path.join(output, "database.sql"));
if (!sql.bytes) fail();
entries.push({ path: "database.sql", type: "file", ...sql });
// Detect source changes across the database dump and the file copy interval.
for (const key of Object.keys(roots)) {
const current = await walk(roots[key], `files/${key}`, undefined, true);
if (
JSON.stringify(current) !==
JSON.stringify(
entries.filter(
(entry) =>
entry.path === `files/${key}` ||
entry.path.startsWith(`files/${key}/`),
),
)
)
fail();
}
entries.sort((left, right) =>
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
);
const manifest = {
format: 1,
complete: true,
createdAt: new Date().toISOString(),
roots: Object.keys(roots).sort(),
database,
entries,
};
for (const entry of entries) validEntry(entry, manifest.roots);
await writeFile(
path.join(output, "manifest.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
{ flag: "wx", mode: 0o600 },
);
await verifyArtifact(output);
return manifest;
} catch (error) {
await rm(output, { recursive: true, force: true });
throw error;
}
}
export async function verifyArtifact(directory) {
await safeDirectory(directory);
const manifestPath = path.join(directory, "manifest.json");
await transfer(manifestPath); // Reject links before parsing the manifest.
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
if (
manifest.format !== 1 ||
manifest.complete !== true ||
!Array.isArray(manifest.roots) ||
requiredRoots.some((root) => !manifest.roots.includes(root)) ||
manifest.roots.some((root) => !allowedRoots.includes(root)) ||
!Array.isArray(manifest.entries)
)
fail();
const names = new Set();
for (const entry of manifest.entries) {
validEntry(entry, manifest.roots);
if (names.has(entry.path)) fail();
names.add(entry.path);
}
if (
!manifest.entries.some(
(entry) =>
entry.path === "database.sql" &&
entry.type === "file" &&
entry.bytes > 0,
)
)
fail();
const actual = (await walk(directory, "artifact"))
.filter(
(entry) =>
entry.path !== "artifact" && entry.path !== "artifact/manifest.json",
)
.map((entry) => ({ ...entry, path: entry.path.slice(9) }));
if (
JSON.stringify(sortedEntries(actual)) !==
JSON.stringify(sortedEntries(manifest.entries))
)
fail();
for (const root of manifest.roots)
if (
!actual.some(
(entry) => entry.path === `files/${root}` && entry.type === "directory",
)
)
fail();
return manifest;
}
export async function restoreFiles(artifact, destination) {
const manifest = await verifyArtifact(artifact);
if (
!path.isAbsolute(destination) ||
inside(path.resolve(artifact), path.resolve(destination))
)
fail();
await safeDirectory(path.dirname(destination));
await mkdir(destination, { mode: 0o700 });
try {
for (const root of manifest.roots) {
const target = path.join(destination, root);
await mkdir(target, { mode: 0o700 });
const entries = await walk(
path.join(artifact, "files", root),
`files/${root}`,
target,
);
const expectedEntries = manifest.entries.filter(
(entry) =>
entry.path === `files/${root}` ||
entry.path.startsWith(`files/${root}/`),
);
if (
JSON.stringify(sortedEntries(entries)) !==
JSON.stringify(sortedEntries(expectedEntries))
)
fail();
}
} catch (error) {
await rm(destination, { recursive: true, force: true });
throw error;
}
return manifest;
}