feat(deploy): add Contabo release workflow and portal payload gate
This commit is contained in:
1 parent
5fa54719b2
commit
ba93a54942
18 files changed
+697
-28
No files matched your search
@@ -0,0 +1,92 @@
|
||||
/** 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 };
|
||||
Reference in new issue
Block a user