Files
aia-website/www/tools/portal-resource-install.mjs
T

93 lines
5.1 KiB
JavaScript

/** Root-only Contabo-side installer for a reviewed employee-resource plan. */
import { createHash } from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
const root = '/srv/www/azinstitute4autism.com';
const roles = new Set(['staff', 'management']);
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
const safeName = (value) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,240}$/.test(value) && value !== '.' && value !== '..';
function fail(message) { throw new Error(message); }
function nginxGroup() {
const result = spawnSync('getent', ['group', 'nginx'], { encoding: 'utf8' });
const gid = Number(result.stdout?.trim().split(':')[2]);
if (!Number.isInteger(gid)) fail('nginx group is unavailable');
return gid;
}
async function readPlan(file) {
const stat = await fs.lstat(file);
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 2_000_000) fail('Resource plan must be a bounded regular file');
const plan = JSON.parse(await fs.readFile(file, 'utf8'));
if (plan.schema !== 1 || !Array.isArray(plan.files) || !/^[0-9a-f]{40}$/.test(plan.sourceCommit ?? '')) fail('Invalid resource installation plan');
const destinations = new Set();
for (const entry of plan.files) {
if (!roles.has(entry.role) || !safeName(entry.name) || !safeName(entry.stageName) || !/^[0-9a-f]{64}$/.test(entry.sha256 ?? '') || !Number.isInteger(entry.size) || entry.size <= 0 || entry.size > 50 * 1024 * 1024) fail('Invalid resource plan entry');
const key = `${entry.role}/${entry.name}`;
if (destinations.has(key)) fail('Duplicate resource destination');
destinations.add(key);
}
return plan;
}
async function verifyStage(stage, plan) {
const expected = new Set(plan.files.map((entry) => `${entry.role}/${entry.stageName}`));
const actual = new Set();
for (const role of roles) {
const directory = path.join(stage, role);
const stat = await fs.lstat(directory).catch(() => null);
if (!stat || !stat.isDirectory() || stat.isSymbolicLink()) fail(`Missing safe ${role} staging directory`);
for (const name of await fs.readdir(directory)) {
const file = path.join(directory, name), relative = `${role}/${name}`, meta = await fs.lstat(file);
if (!meta.isFile() || meta.isSymbolicLink() || meta.nlink !== 1) fail(`Unsafe staged resource: ${relative}`);
actual.add(relative);
}
}
if (actual.size !== expected.size || [...actual].some((value) => !expected.has(value))) fail('Staged resources do not exactly match the reviewed plan');
for (const entry of plan.files) {
const source = path.join(stage, entry.role, entry.stageName);
const bytes = await fs.readFile(source);
if (bytes.length !== entry.size || sha256(bytes) !== entry.sha256) fail(`Staged resource digest mismatch: ${entry.role}/${entry.name}`);
}
}
async function install(stage, plan) {
if (process.getuid?.() !== 0) fail('Resource installation requires root');
const expectedStage = path.join(root, 'staging', `portal-resources-${plan.sourceCommit}`);
if (path.resolve(stage) !== expectedStage) fail('Resource stage is outside the approved root-controlled staging directory');
await verifyStage(stage, plan);
const gid = nginxGroup();
const installed = [];
for (const entry of plan.files) {
const source = path.join(stage, entry.role, entry.stageName);
const destinationDirectory = path.join(root, 'secure-resources', entry.role);
const destination = path.join(destinationDirectory, entry.name);
const existing = await fs.lstat(destination).catch(() => null);
if (existing) {
if (!existing.isFile() || existing.isSymbolicLink()) fail(`Unsafe existing resource destination: ${entry.role}/${entry.name}`);
const bytes = await fs.readFile(destination);
if (bytes.length !== entry.size || sha256(bytes) !== entry.sha256) fail(`Refusing to replace existing resource: ${entry.role}/${entry.name}`);
installed.push({ role: entry.role, name: entry.name, result: 'already-verified' });
continue;
}
const temporary = path.join(destinationDirectory, `.${entry.name}.next-${process.pid}`);
await fs.copyFile(source, temporary, fs.constants.COPYFILE_EXCL);
await fs.chown(temporary, 0, gid);
await fs.chmod(temporary, 0o640);
await fs.rename(temporary, destination);
installed.push({ role: entry.role, name: entry.name, result: 'installed' });
}
return installed;
}
if (process.argv[1] && path.resolve(process.argv[1]) === new URL(import.meta.url).pathname) {
const [stage, manifest] = process.argv.slice(2);
(async () => {
if (!stage || !manifest || process.argv.length !== 4) fail('Usage: portal-resource-install.mjs STAGING_DIRECTORY REVIEWED_PLAN.json');
const plan = await readPlan(manifest);
const installed = await install(stage, plan);
console.log(JSON.stringify({ sourceCommit: plan.sourceCommit, installed: installed.length, identifiers: installed }, null, 2));
})().catch((error) => { console.error(`Employee resource installation stopped: ${error.message}`); process.exitCode = 1; });
}
export { readPlan, verifyStage, install };