302 lines
16 KiB
JavaScript
Executable File
302 lines
16 KiB
JavaScript
Executable File
// Offline, private M0 recovery evidence, NOT a public-readiness certification.
|
|
// Only copies are booted. Stop external engines first; locks/process discovery
|
|
// cannot prove that a remote machine has not opened a shared world directory.
|
|
// SHA256 is integrity evidence, not authentication or encryption. Runtime/backups
|
|
// must be on an access-controlled (preferably encrypted) volume, outside webroots.
|
|
import * as fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { execFile } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
import { createHash, randomUUID } from 'node:crypto';
|
|
import dgram from 'node:dgram';
|
|
import { absolute, disjoint, ensureDirectory, inventory, json, safePath, withLauncherLock } from './files.mjs';
|
|
import { assertWorldOffline, backupWorld, restoreWorld, verifyBackup } from './backup.mjs';
|
|
import { packageGame, SOURCE, verifyPackage } from './package.mjs';
|
|
import { engineExecutable, runtimeHome, validateEngine } from './launcher.mjs';
|
|
|
|
const exec = promisify(execFile);
|
|
const digest = value => createHash('sha256').update(json(value)).digest('hex');
|
|
const same = (left, right) => {
|
|
if (json(left) !== json(right)) throw new Error('Recovery SHA256 inventory mismatch.');
|
|
};
|
|
const privateWrite = (filename, value) => fs.writeFile(filename, value, { flag: 'wx', mode: 0o600 });
|
|
class RecoveryError extends Error {
|
|
constructor(category) { super(category); this.category = category; }
|
|
}
|
|
const failure = category => new RecoveryError(category);
|
|
|
|
async function assertNoEnginesOrWriters() {
|
|
// Fail closed on process-discovery errors. Never print command lines: they can
|
|
// contain private paths, locations or credentials belonging to other apps.
|
|
if (process.platform === 'win32') {
|
|
// Image names/PIDs only; no nested shell or private command-line discovery.
|
|
// The exclusive runtime lease serializes managed Node writers, not unrelated
|
|
// Node previews. External/shared-world writers must still be stopped by owner.
|
|
const executable = path.join(absolute(process.env.SystemRoot || 'C:\\Windows'), 'System32/tasklist.exe');
|
|
const { stdout } = await exec(executable, ['/fo', 'csv', '/nh'], {
|
|
shell: false, windowsHide: true, encoding: 'utf8', timeout: 15_000, maxBuffer: 4 * 1024 * 1024,
|
|
});
|
|
let rows = 0;
|
|
for (const line of stdout.trim().split(/\r?\n/)) {
|
|
const match = /^"([^"\r\n]+)","(\d+)",/.exec(line);
|
|
if (!match) throw failure('process-discovery-invalid');
|
|
rows++;
|
|
if (/^(?:luanti|minetest)(?:-?server)?\.exe$/i.test(match[1])) throw failure('engine-process-active');
|
|
}
|
|
if (!rows) throw failure('process-discovery-empty');
|
|
} else {
|
|
const { stdout } = await exec('/bin/ps', ['-eo', 'pid=,comm=,args='], {
|
|
shell: false, encoding: 'utf8', timeout: 10_000, maxBuffer: 4 * 1024 * 1024,
|
|
});
|
|
for (const line of stdout.split('\n')) {
|
|
const match = /^\s*(\d+)\s+(\S+)\s+(.*)$/.exec(line);
|
|
if (!match || Number(match[1]) === process.pid) continue;
|
|
if (/^(?:luanti|minetest)(?:-?server)?$/i.test(path.basename(match[2])) ||
|
|
(/^node(?:js)?$/i.test(path.basename(match[2])) && /scripts\/(?:run|backup|package|recovery)\.mjs/.test(match[3]))) {
|
|
throw new Error('Stop all Luanti engines and other Hearth tooling before recovery.');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseWorld(text) {
|
|
const settings = new Map();
|
|
for (const line of text.split(/\r?\n/)) {
|
|
if (!line.trim() || /^\s*#/.test(line)) continue;
|
|
const match = /^\s*([a-zA-Z0-9_]+)\s*=\s*(.*?)\s*$/.exec(line);
|
|
if (!match || settings.has(match[1])) throw new Error('Ambiguous world settings; recovery refused.');
|
|
settings.set(match[1], match[2]);
|
|
}
|
|
if (settings.get('gameid') !== 'last_hearth' ||
|
|
['backend', 'player_backend', 'auth_backend', 'mod_storage_backend'].some(key => settings.get(key) !== 'sqlite3')) {
|
|
throw new Error('Recovery requires an existing last_hearth world with local SQLite backends.');
|
|
}
|
|
return settings;
|
|
}
|
|
|
|
async function isolatedWorld(world, gameId) {
|
|
// Byte equality is checked BEFORE these copy-only changes. Do not execute old
|
|
// worldmods (including stale hearth_smoke), or inherit arbitrary world settings.
|
|
await assertWorldOffline(world);
|
|
parseWorld(await fs.readFile(path.join(world, 'world.mt'), 'utf8'));
|
|
await fs.rm(path.join(world, 'worldmods'), { recursive: true, force: true });
|
|
const mod = path.join(world, 'worldmods/hearth_recovery');
|
|
await ensureDirectory(mod);
|
|
await privateWrite(path.join(mod, 'mod.conf'), 'name = hearth_recovery\ndepends = hearth\ndescription = Private offline M0 recovery probe\n');
|
|
await fs.copyFile(await safePath(path.join(SOURCE, 'tests/recovery_probe.lua')), path.join(mod, 'probe.lua'));
|
|
await privateWrite(path.join(mod, 'init.lua'), 'dofile(core.get_modpath("hearth_recovery") .. "/probe.lua")("probe")\n');
|
|
await fs.writeFile(path.join(world, 'world.mt'), [
|
|
`gameid = ${gameId}`, 'backend = sqlite3', 'player_backend = sqlite3',
|
|
'auth_backend = sqlite3', 'mod_storage_backend = sqlite3',
|
|
'load_mod_hearth = true', 'load_mod_hearth_recovery = true', '',
|
|
].join('\n'), { mode: 0o600 });
|
|
}
|
|
|
|
async function availablePort() {
|
|
const socket = dgram.createSocket('udp4');
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
socket.once('error', reject);
|
|
socket.bind(0, '127.0.0.1', resolve);
|
|
});
|
|
return socket.address().port;
|
|
} finally { socket.close(); }
|
|
}
|
|
|
|
async function boot(executable, stage, world, phase, token, env, gameId) {
|
|
await assertNoEnginesOrWriters();
|
|
await assertWorldOffline(world);
|
|
const config = path.join(stage, `recovery-${phase}.conf`);
|
|
await privateWrite(config, [
|
|
'hearth_environment = staging', 'hearth_latitude = 45', 'hearth_longitude = 0',
|
|
`hearth_recovery_phase = ${phase}`, `hearth_recovery_token = ${token}`,
|
|
`hearth_recovery_gameid = ${gameId}`,
|
|
'bind_address = 127.0.0.1', `port = ${await availablePort()}`, 'enable_ipv6 = false',
|
|
'max_users = 0', 'server_announce = false', 'enable_pvp = false',
|
|
'secure.enable_security = true', 'secure.trusted_mods =', 'secure.http_mods =',
|
|
'enable_mod_channels = false', 'time_speed = 0', 'dedicated_server_step = 0.05', '',
|
|
].join('\n'));
|
|
// Deliberately do not stream raw engine output. It can contain private player
|
|
// names, world positions, parser data and URLs. Keep it bounded in memory only;
|
|
// the private temporary logfile is deleted with the stage even after failures.
|
|
let result;
|
|
try {
|
|
result = await exec(executable, ['--server', '--world', world, '--gameid', gameId,
|
|
'--config', config, '--logfile', path.join(stage, `engine-${phase}.log`)], {
|
|
cwd: stage, env: { ...env, LUANTI_GAME_PATH: path.join(stage, 'games') },
|
|
shell: false, windowsHide: true, encoding: 'utf8', timeout: 90_000,
|
|
killSignal: 'SIGKILL', maxBuffer: 4 * 1024 * 1024,
|
|
});
|
|
} catch (error) {
|
|
const output = String(error.stdout || '') + '\n' + String(error.stderr || '');
|
|
throw failure(`${phase}-${probeFailure(output) || (error.killed ? 'engine-timeout' :
|
|
error.code === 'ENOENT' ? 'engine-not-found' : 'engine-exit')}`);
|
|
}
|
|
const output = result.stdout + '\n' + result.stderr;
|
|
const marker = `LAST_HEARTH_RECOVERY_PASS ${phase} ${token}`;
|
|
if (output.split(marker).length !== 2 || output.includes('LAST_HEARTH_RECOVERY_FAIL')) {
|
|
throw failure(`${phase}-${probeFailure(output) || 'shutdown-marker-missing'}`);
|
|
}
|
|
await assertWorldOffline(world);
|
|
const summary = JSON.parse(await fs.readFile(path.join(world, `m0-recovery-${phase}.json`), 'utf8'));
|
|
if (summary.format !== 1 || summary.phase !== phase || summary.token !== token ||
|
|
!Number.isSafeInteger(summary.existingKeys) || summary.existingKeys < 1 || summary.existingKeys > 4 ||
|
|
!Number.isSafeInteger(summary.legacyKeys) || summary.legacyKeys < 1 || summary.legacyKeys > 4 ||
|
|
!Number.isSafeInteger(summary.claimCount) || summary.claimCount < 0 ||
|
|
summary.noClients !== true || summary.fixture !== true || summary.spawn !== true ||
|
|
summary.claims !== true || summary.homes !== true) {
|
|
throw new Error('Recovery probe summary is incomplete.');
|
|
}
|
|
if (!Number.isSafeInteger(summary.verifiedAssertions) || summary.verifiedAssertions < 1) throw failure(`${phase}-assertion-count-missing`);
|
|
return summary;
|
|
}
|
|
|
|
function probeFailure(output) {
|
|
// Only accept a static assertion/checkpoint label, never parser strings,
|
|
// filenames, records, or arbitrary engine errors as a diagnostic label.
|
|
const match = /LAST_HEARTH_RECOVERY_FAIL (checkpoint-[a-z-]{1,96})(?=\s|$)/.exec(output);
|
|
return match ? `probe-${match[1]}` : undefined;
|
|
}
|
|
|
|
export async function recover({ env = process.env } = {}) {
|
|
let step = 'runtime-preflight';
|
|
try {
|
|
const runtime = await safePath(runtimeHome(env));
|
|
// HEARTH_HOME changes tooling storage, never which original world is selected.
|
|
if (!env.HEARTH_RECOVERY_SOURCE && !env.LOCALAPPDATA) {
|
|
throw new Error('Set HEARTH_RECOVERY_SOURCE or LOCALAPPDATA.');
|
|
}
|
|
const source = await safePath(absolute(env.HEARTH_RECOVERY_SOURCE ||
|
|
path.join(absolute(env.LOCALAPPDATA), 'last-hearth-runtime/worlds/last_hearth')));
|
|
const executable = engineExecutable(env, process.platform, runtime);
|
|
step = 'process-discovery';
|
|
await assertNoEnginesOrWriters();
|
|
step = 'engine-validation';
|
|
const engineVersion = await validateEngine(executable);
|
|
// Refuse to run against partially wired code. The Lua probe also checks the
|
|
// actual loaded API: this textual gate alone is not evidence of integration.
|
|
const init = await fs.readFile(path.join(SOURCE, 'mods/hearth/init.lua'), 'utf8');
|
|
if (!/hearth\.persistence\s*=/.test(init)) throw new Error('Recovery deferred: init must wire hearth.persistence first.');
|
|
step = 'runtime-lock';
|
|
return await withLauncherLock(runtime, async lease => {
|
|
step = 'source-offline-inventory';
|
|
await assertNoEnginesOrWriters();
|
|
const original = await assertWorldOffline(source);
|
|
parseWorld(await fs.readFile(path.join(source, 'world.mt'), 'utf8'));
|
|
for (const name of ['m0-recovery-expected.json', 'm0-recovery-fixture.json']) {
|
|
if (original.some(entry => entry.path === name)) throw new Error('Source already contains recovery instrumentation.');
|
|
}
|
|
const backups = path.join(runtime, 'backups');
|
|
disjoint(source, backups);
|
|
await ensureDirectory(backups);
|
|
const token = randomUUID();
|
|
// Installed games precede LUANTI_GAME_PATH in Luanti's search order.
|
|
// A unique disposable ID prevents an old installed game shadowing this
|
|
// verified package, without touching the installation or original world.
|
|
const gameId = `last_hearth_recovery_${token.replaceAll('-', '')}`;
|
|
const retained = path.join(backups, `m0-validation-${new Date().toISOString().replace(/[:.]/g, '-')}-${token}`);
|
|
const stage = await fs.mkdtemp(path.join(runtime, 'recovery-'));
|
|
await fs.chmod(stage, 0o700);
|
|
disjoint(source, stage);
|
|
const options = { offline: true, runtime, lease };
|
|
try {
|
|
step = 'original-backup';
|
|
// Retain the untouched original snapshot, even if a later boot fails.
|
|
await backupWorld(source, retained, options);
|
|
await verifyBackup(retained);
|
|
same(original, await inventory(path.join(retained, 'world')));
|
|
const first = path.join(stage, 'restored-first');
|
|
step = 'first-restore';
|
|
await verifyBackup(retained); // Supplied backup SHA256, BEFORE restore.
|
|
await restoreWorld(retained, first, options);
|
|
same(original, await inventory(first)); // Includes original player DB bytes.
|
|
await ensureDirectory(path.join(stage, 'games'));
|
|
const game = path.join(stage, 'games', gameId);
|
|
const inputs = await inventory(path.join(SOURCE, 'mods'));
|
|
step = 'game-package';
|
|
await packageGame(game);
|
|
same(inputs, await inventory(path.join(SOURCE, 'mods')));
|
|
const packaged = await verifyPackage(game);
|
|
// Instrument only the disposable package, before its real hearth init.
|
|
// This reads the engine's own ModStorageRef in the hearth mod context,
|
|
// capturing exact pre-migration bytes without Node SQLite or global mocks.
|
|
const gameInit = path.join(game, 'mods/hearth/init.lua');
|
|
await fs.writeFile(gameInit,
|
|
'dofile(core.get_worldpath() .. "/worldmods/hearth_recovery/probe.lua")("capture")\n' +
|
|
await fs.readFile(gameInit, 'utf8'), { mode: 0o600 });
|
|
await isolatedWorld(first, gameId);
|
|
step = 'seed-boot';
|
|
const seeded = await boot(executable, stage, first, 'seed', token, env, gameId);
|
|
step = 'second-backup-restore';
|
|
const afterFirst = await assertWorldOffline(first);
|
|
const secondBackup = path.join(stage, 'after-first-backup');
|
|
await backupWorld(first, secondBackup, options);
|
|
await verifyBackup(secondBackup);
|
|
same(afterFirst, await inventory(path.join(secondBackup, 'world')));
|
|
const second = path.join(stage, 'restored-second');
|
|
await restoreWorld(secondBackup, second, options);
|
|
same(afterFirst, await inventory(second));
|
|
step = 'restart-boot';
|
|
const restarted = await boot(executable, stage, second, 'restart', token, env, gameId);
|
|
if (seeded.existingKeys !== restarted.existingKeys || seeded.legacyKeys !== restarted.legacyKeys ||
|
|
seeded.claimCount !== restarted.claimCount) {
|
|
throw new Error('Restart migration evidence mismatch.');
|
|
}
|
|
step = 'final-integrity-verification';
|
|
await assertNoEnginesOrWriters();
|
|
same(original, await assertWorldOffline(source));
|
|
await verifyBackup(retained);
|
|
same(original, await inventory(path.join(retained, 'world')));
|
|
// No raw records, player names, filesystem paths, coordinates or URLs in
|
|
// the retained report. Credentials are neither generated nor required.
|
|
const report = {
|
|
format: 1, milestone: 'M2', publicReady: false, engineVersion,
|
|
sourceUnchanged: true, backupVerified: true, restoresByteExact: 2,
|
|
successfulEngineShutdowns: 2, noClients: true,
|
|
verifiedAssertions: { seed: seeded.verifiedAssertions, restart: restarted.verifiedAssertions,
|
|
total: seeded.verifiedAssertions + restarted.verifiedAssertions },
|
|
existingKeys: seeded.existingKeys, legacyKeys: seeded.legacyKeys, claimCount: seeded.claimCount,
|
|
migratedRecordsPreserved: true, immutableLegacyBackups: true,
|
|
savedSpawnVerified: true, claimsProtectionVerified: true, homesVerified: true,
|
|
playerAdapterFixture: true, nodeMetadataInventoryRestored: true, absoluteFireExpiry: true,
|
|
sourceFiles: original.filter(entry => entry.type === 'file').length,
|
|
sourceInventorySha256: digest(original),
|
|
packageFiles: packaged.entries.filter(entry => entry.type === 'file').length,
|
|
packageInventorySha256: digest(packaged.entries),
|
|
firstBootInventorySha256: digest(afterFirst),
|
|
};
|
|
// Never add files to the immutable snapshot, even transiently: interruption
|
|
// while publishing a report must not invalidate its exact SHA256 manifest.
|
|
step = 'report-publication';
|
|
await privateWrite(retained + '-report.json', json(report));
|
|
return report;
|
|
} finally {
|
|
// Never delete source, retained backup, or arbitrary shared paths. An
|
|
// engine child is synchronously reaped before this private stage is removed.
|
|
try { same(original, await assertWorldOffline(source)); }
|
|
finally {
|
|
await safePath(stage);
|
|
await fs.rm(stage, { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof RecoveryError) throw error;
|
|
const code = ['EEXIST', 'ENOENT', 'EACCES', 'EPERM', 'EBUSY', 'ETIMEDOUT'].includes(error.code) ? `-${error.code}` : '';
|
|
throw failure(step + code);
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
try {
|
|
if (process.argv.length !== 2) throw new Error('Recovery accepts no arguments; use HEARTH_RECOVERY_SOURCE.');
|
|
const report = await recover();
|
|
process.stdout.write(json(report));
|
|
} catch (error) {
|
|
// Error objects/engine stderr can carry source paths and original save data.
|
|
process.stderr.write(`LAST_HEARTH_RECOVERY_FAIL: ${error instanceof RecoveryError ? error.category : 'invalid-invocation'}; original world was never launched.\n`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|