fix(deploy): make portal installer runtime portable

This commit is contained in:
DeCentN2Madness authored and AIAMarketing committed 2026-11-05 00:40:18 -07:00
1 parent 4987f5ee1a
commit 36d417a422
8 files changed
+272 -132

No files matched your search

+4 -3
View File
@@ -616,9 +616,10 @@ for those explicit builds. No actual submissions run in automated tests.
The Contabo deployment interface is intentionally separate from NixOS changes. The Contabo deployment interface is intentionally separate from NixOS changes.
Use [the release runbook](integrations/nixos/deploy.md) for `npm run deploy`, Use [the release runbook](integrations/nixos/deploy.md) for `npm run deploy`,
dry-run, rollback, the encrypted production portal input and the separate dry-run, rollback, the encrypted production portal input and the separate
one-time employee-resource installer. Ordinary releases never copy employee one-time employee-resource installer. The server-side installer is a root-only
documents into `dist`; protected staff and management resources remain under POSIX shell script using NixOS base tools; it does not require Node.js on
server-controlled Basic Auth namespaces. The accepted interim portal is Contabo. Ordinary releases never copy employee documents into `dist`;
protected staff and management resources remain under server-controlled Basic Auth namespaces. The accepted interim portal is
explicitly non-PHI. explicitly non-PHI.
### Google Ads consultation-form conversion handoff ### Google Ads consultation-form conversion handoff
+6 -4
View File
@@ -61,7 +61,7 @@ npm run deploy:rollback -- FULL_APPROVED_SHA --confirm
## Employee resources: separate one-time operation ## Employee resources: separate one-time operation
Approved non-PHI employee files are never part of an ordinary release. After Approved non-PHI employee files are never part of an ordinary release. After
the website release that contains `portal-resource-install.mjs` is promoted and the website release that contains `portal-resource-install.sh` is promoted and
the VPS SOPS-backed Basic Auth material is independently ready: the VPS SOPS-backed Basic Auth material is independently ready:
```sh ```sh
@@ -72,9 +72,11 @@ AIA_DEPLOY_SSH_IDENTITY="$HOME/.ssh/AIAMarketing" \
npm run deploy:portal-resources -- --confirm npm run deploy:portal-resources -- --confirm
``` ```
The installer reads only the private collection manifest, accepts approved The installer invokes the promoted root-only `portal-resource-install.sh` through
`STAFF-INTERNAL` and `MANAGEMENT-INTERNAL` mappings to `/staff-resources/` and NixOS's base `/bin/sh` and coreutils path; it does not depend on a globally
`/management-resources/`, verifies every local hash, rejects links/traversal and installed Node.js runtime. It reads only the private collection manifest,
accepts approved `STAFF-INTERNAL` and `MANAGEMENT-INTERNAL` mappings to
`/staff-resources/` and `/management-resources/`, verifies every local hash, rejects links/traversal and
uploads a root-controlled temporary tree. It never uses `--delete` for resource uploads a root-controlled temporary tree. It never uses `--delete` for resource
directories and refuses to replace a different existing file. The VPS installs directories and refuses to replace a different existing file. The VPS installs
regular files as `root:nginx` mode `0640` and reports metadata only. regular files as `root:nginx` mode `0640` and reports metadata only.
+13 -4
View File
@@ -56,6 +56,14 @@ export async function prepareResourcePlan(privateRoot, staging, sourceCommit) {
if (!files.length) fail('No deployment-approved internal resources are present; reconcile the private manifest first'); if (!files.length) fail('No deployment-approved internal resources are present; reconcile the private manifest first');
return { schema: 1, sourceCommit, files: files.sort((a, b) => `${a.role}/${a.name}`.localeCompare(`${b.role}/${b.name}`)) }; return { schema: 1, sourceCommit, files: files.sort((a, b) => `${a.role}/${a.name}`.localeCompare(`${b.role}/${b.name}`)) };
} }
export function serializeResourcePlan(plan) {
if (plan?.schema !== 1 || !fullSha.test(plan.sourceCommit ?? '') || !Array.isArray(plan.files)) fail('Invalid resource installation plan');
const records = plan.files.map((entry) => {
if (!['staff', 'management'].includes(entry.role) || !safeName(entry.name) || !safeName(entry.stageName) || !/^[0-9a-f]{64}$/.test(entry.sha256 ?? '') || !Number.isInteger(entry.size) || entry.size <= 0) fail('Invalid resource installation plan entry');
return ['file', entry.role, entry.name, entry.stageName, entry.sha256, String(entry.size)].join('\t');
});
return `AIA_PORTAL_RESOURCE_PLAN_V1\nsourceCommit=${plan.sourceCommit}\n${records.join('\n')}\n`;
}
function parse(argv) { function parse(argv) {
const valid = new Set(['--dry-run','--confirm']); const valid = new Set(['--dry-run','--confirm']);
if (!argv.length || argv.some((arg) => !valid.has(arg)) || (argv.includes('--dry-run') && argv.includes('--confirm'))) fail('Usage: npm run deploy:portal-resources -- --dry-run | npm run deploy:portal-resources -- --confirm'); if (!argv.length || argv.some((arg) => !valid.has(arg)) || (argv.includes('--dry-run') && argv.includes('--confirm'))) fail('Usage: npm run deploy:portal-resources -- --dry-run | npm run deploy:portal-resources -- --confirm');
@@ -69,14 +77,15 @@ async function main() {
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-portal-resources-')); const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-portal-resources-'));
try { try {
const staging = path.join(temporary, `portal-resources-${sha}`); const staging = path.join(temporary, `portal-resources-${sha}`);
await Promise.all(['staff', 'management'].map((role) => fs.mkdir(path.join(staging, role), { recursive: true, mode: 0o700 })));
const plan = await prepareResourcePlan(privateRoot, staging, sha); const plan = await prepareResourcePlan(privateRoot, staging, sha);
const planFile = path.join(temporary, `aia-portal-resources-${sha}.json`); const planFile = path.join(temporary, `aia-portal-resources-${sha}.tsv`);
await fs.writeFile(planFile, JSON.stringify(plan, null, 2) + '\n', { mode: 0o600 }); await fs.writeFile(planFile, serializeResourcePlan(plan), { mode: 0o600 });
if (options.dryRun) { console.log(JSON.stringify({ dryRun: true, sourceCommit: sha, resources: plan.files.map(({ role, name, sha256, size }) => ({ role, name, sha256, size })) }, null, 2)); return; } if (options.dryRun) { console.log(JSON.stringify({ dryRun: true, sourceCommit: sha, resources: plan.files.map(({ role, name, sha256, size }) => ({ role, name, sha256, size })) }, null, 2)); return; }
if (!options.confirm) fail('Refusing employee-file transfer without --confirm'); if (!options.confirm) fail('Refusing employee-file transfer without --confirm');
const host = process.env.AIA_DEPLOY_HOST, identity = process.env.AIA_DEPLOY_SSH_IDENTITY; const host = process.env.AIA_DEPLOY_HOST, identity = process.env.AIA_DEPLOY_SSH_IDENTITY;
const [ssh, ...sshOptions] = sshTarget(host, identity); const [ssh, ...sshOptions] = sshTarget(host, identity);
const remoteStage = `${root}/staging/portal-resources-${sha}`, remotePlan = `/root/aia-portal-resources-${sha}.json`; const remoteStage = `${root}/staging/portal-resources-${sha}`, remotePlan = `/root/aia-portal-resources-${sha}.tsv`;
const transport = `${ssh} ${sshOptions.map((item) => `'${item.replaceAll("'", "'\\''")}'`).join(' ')}`; const transport = `${ssh} ${sshOptions.map((item) => `'${item.replaceAll("'", "'\\''")}'`).join(' ')}`;
run(ssh, [...sshOptions, host, `install -d -m 0700 ${remoteStage}/staff ${remoteStage}/management`]); run(ssh, [...sshOptions, host, `install -d -m 0700 ${remoteStage}/staff ${remoteStage}/management`]);
for (const role of ['staff','management']) { for (const role of ['staff','management']) {
@@ -85,7 +94,7 @@ async function main() {
run('rsync', args); run('rsync', args);
} }
run('rsync', ['-rlt', '--chown=root:root', '--chmod=Fu=rw,Fgo=', '-e', transport, planFile, `${host}:${remotePlan}`]); run('rsync', ['-rlt', '--chown=root:root', '--chmod=Fu=rw,Fgo=', '-e', transport, planFile, `${host}:${remotePlan}`]);
run(ssh, [...sshOptions, host, `node ${root}/www/tools/portal-resource-install.mjs ${remoteStage} ${remotePlan}`]); run(ssh, [...sshOptions, host, `env -i PATH=/run/current-system/sw/bin:/usr/bin:/bin HOME=/root /bin/sh ${root}/www/tools/portal-resource-install.sh ${remoteStage} ${remotePlan}`]);
console.log(JSON.stringify({ installed: true, sourceCommit: sha, resources: plan.files.length }, null, 2)); console.log(JSON.stringify({ installed: true, sourceCommit: sha, resources: plan.files.length }, null, 2));
} finally { await fs.rm(temporary, { recursive: true, force: true }); } } finally { await fs.rm(temporary, { recursive: true, force: true }); }
} }
+64 -13
View File
@@ -3,25 +3,76 @@ import { createHash } from 'node:crypto';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import test from 'node:test'; import test from 'node:test';
import { prepareResourcePlan } from './deploy-portal-resources.mjs'; import { prepareResourcePlan, serializeResourcePlan } from './deploy-portal-resources.mjs';
const digest = (value) => createHash('sha256').update(value).digest('hex'); const digest = (value) => createHash('sha256').update(value).digest('hex');
const sha = 'a'.repeat(40);
const scriptSource = path.join(path.dirname(fileURLToPath(import.meta.url)), 'portal-resource-install.sh');
async function fixture(prefix = 'aia-resource-plan-') {
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
const privateRoot = path.join(root, 'private');
const hubfs = path.join(privateRoot, 'hubfs');
const stage = path.join(root, 'staging', `portal-resources-${sha}`);
await fs.mkdir(hubfs, { recursive: true });
await fs.mkdir(path.join(root, 'secure-resources', 'staff'), { recursive: true });
await fs.mkdir(path.join(root, 'secure-resources', 'management'), { recursive: true });
return { root, privateRoot, hubfs, stage };
}
async function writeApprovedResource(privateRoot, hubfs, bytes = Buffer.from('synthetic non-phi document')) {
const localFile = 'a'.repeat(64);
await fs.writeFile(path.join(hubfs, localFile), bytes, { mode: 0o600 });
await fs.writeFile(path.join(privateRoot, 'manifest.json'), JSON.stringify({ resources: [{
classification: 'STAFF-INTERNAL', deliveryPath: '/staff-resources/guide.pdf', destinationImplemented: true, destinationVerified: true, destinationAccessControlled: true, rewriteApproved: true, localFile, sha256: digest(bytes), size: bytes.length,
}] }));
return { localFile, bytes };
}
test('resource plan copies only hash-verified approved protected resources', async () => { test('resource plan copies only hash-verified approved protected resources', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-resource-plan-')); const { root, privateRoot, hubfs, stage } = await fixture();
try { try {
const hubfs = path.join(root, 'hubfs'), stage = path.join(root, 'stage'); const { localFile, bytes } = await writeApprovedResource(privateRoot, hubfs);
await fs.mkdir(hubfs, { recursive: true }); const plan = await prepareResourcePlan(privateRoot, stage, sha);
const bytes = Buffer.from('synthetic non-phi document');
const localFile = 'a'.repeat(64);
await fs.writeFile(path.join(hubfs, localFile), bytes, { mode: 0o600 });
await fs.writeFile(path.join(root, 'manifest.json'), JSON.stringify({ resources: [{
classification: 'STAFF-INTERNAL', deliveryPath: '/staff-resources/guide.pdf', destinationImplemented: true, destinationVerified: true, destinationAccessControlled: true, rewriteApproved: true, localFile, sha256: digest(bytes), size: bytes.length,
}] }));
const plan = await prepareResourcePlan(root, stage, 'a'.repeat(40));
assert.equal(plan.files.length, 1); assert.equal(plan.files.length, 1);
assert.equal((await fs.readFile(path.join(stage, 'staff', localFile))).toString(), bytes.toString()); assert.equal((await fs.readFile(path.join(stage, 'staff', localFile))).toString(), bytes.toString());
await fs.writeFile(path.join(root, 'manifest.json'), JSON.stringify({ resources: [{ classification: 'STAFF-INTERNAL', deliveryPath: '/staff-resources/../bad', localFile, sha256: digest(bytes), size: bytes.length }] })); assert.match(serializeResourcePlan(plan), /^AIA_PORTAL_RESOURCE_PLAN_V1\nsourceCommit=a{40}\nfile\tstaff\tguide\.pdf\t/);
await assert.rejects(prepareResourcePlan(root, path.join(root, 'second'), 'a'.repeat(40)), /destination/); await fs.writeFile(path.join(privateRoot, 'manifest.json'), JSON.stringify({ resources: [{ classification: 'STAFF-INTERNAL', deliveryPath: '/staff-resources/../bad', localFile, sha256: digest(bytes), size: bytes.length }] }));
await assert.rejects(prepareResourcePlan(privateRoot, path.join(root, 'second'), sha), /destination/);
} finally { await fs.rm(root, { recursive: true, force: true }); }
});
test('portable shell installer executes the reviewed staged-plan interface with synthetic files', async () => {
const { root, privateRoot, hubfs, stage } = await fixture('aia-portal-install-');
try {
const { bytes } = await writeApprovedResource(privateRoot, hubfs);
const plan = await prepareResourcePlan(privateRoot, stage, sha);
await fs.mkdir(path.join(stage, 'management'), { recursive: true });
const planFile = path.join(root, 'approved-plan.tsv');
await fs.writeFile(planFile, serializeResourcePlan(plan), { mode: 0o600 });
const fakeBin = path.join(root, 'fake-bin');
const log = path.join(root, 'ownership.log');
await fs.mkdir(fakeBin);
await fs.writeFile(path.join(fakeBin, 'id'), '#!/bin/sh\n[ "$1" = -u ] && { echo 0; exit 0; }\nexec /usr/bin/id "$@"\n', { mode: 0o755 });
await fs.writeFile(path.join(fakeBin, 'getent'), '#!/bin/sh\n[ "$1" = group ] && [ "$2" = nginx ] && { echo "nginx:x:999:"; exit 0; }\nexec /run/current-system/sw/bin/getent "$@"\n', { mode: 0o755 });
await fs.writeFile(path.join(fakeBin, 'install'), `#!/bin/sh\nprintf '%s\\n' "$*" >> ${JSON.stringify(log)}\nwhile [ "$#" -gt 0 ]; do\n case "$1" in -o|-g|-m) shift 2 ;; *) break ;; esac\ndone\nexec /usr/bin/install -m 0640 "$@"\n`, { mode: 0o755 });
const script = path.join(root, 'portal-resource-install.sh');
const source = await fs.readFile(scriptSource, 'utf8');
assert.ok(!source.includes('node '));
await fs.writeFile(script, source.replace('ROOT=/srv/www/azinstitute4autism.com', `ROOT=${root}`), { mode: 0o755 });
const result = spawnSync('/bin/sh', [script, stage, planFile], { encoding: 'utf8', env: { ...process.env, PATH: process.env.PATH, AIA_PORTAL_INSTALL_TEST_PATH: fakeBin } });
assert.equal(result.status, 0, result.stderr);
assert.equal((await fs.readFile(path.join(root, 'secure-resources/staff/guide.pdf'))).toString(), bytes.toString());
assert.match(await fs.readFile(log, 'utf8'), /-o root -g nginx -m 0640/);
const nonRoot = spawnSync('/bin/sh', [script, stage, planFile], { encoding: 'utf8', env: { ...process.env, PATH: process.env.PATH } });
assert.notEqual(nonRoot.status, 0);
assert.match(nonRoot.stderr, /requires root/);
await fs.writeFile(path.join(stage, 'staff', 'unexpected.pdf'), 'unexpected');
const rejected = spawnSync('/bin/sh', [script, stage, planFile], { encoding: 'utf8', env: { ...process.env, PATH: process.env.PATH, AIA_PORTAL_INSTALL_TEST_PATH: fakeBin } });
assert.notEqual(rejected.status, 0);
assert.match(rejected.stderr, /Unexpected staged resource/);
} finally { await fs.rm(root, { recursive: true, force: true }); } } finally { await fs.rm(root, { recursive: true, force: true }); }
}); });
+25 -16
View File
@@ -1,30 +1,39 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import test from 'node:test'; import test from 'node:test';
import { releaseManifest, rsyncArguments, sshTarget } from './deploy.mjs'; import { releaseManifest, rsyncArguments, sshTarget } from './deploy.mjs';
test('site manifest follows the Contabo schema and detects unsafe public output', async () => { const fixturePath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures', 'contabo-release-manifest-v1.json');
const fixture = JSON.parse(await fs.readFile(fixturePath, 'utf8'));
async function writeContractArtifact(root) {
for (const [relative, contents] of Object.entries(fixture.input.files)) {
const target = path.join(root, relative);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, contents);
}
}
test('site manifest follows the pinned merged-Contabo release contract and detects unsafe public output', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-deploy-manifest-')); const root = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-deploy-manifest-'));
try { try {
await fs.mkdir(path.join(root, '_astro')); await writeContractArtifact(root);
await fs.writeFile(path.join(root, 'index.html'), '<title>Synthetic</title>'); const manifest = await releaseManifest(root, fixture.input.sourceCommit);
await fs.writeFile(path.join(root, '_astro', 'entry.abcdefgh.js'), 'export {}'); assert.deepEqual(manifest, fixture.expected);
const manifest = await releaseManifest(root, 'a'.repeat(40)); const serverTool = process.env.AIA_CONTABO_RELEASE_TOOL;
assert.deepEqual(Object.keys(manifest).sort(), ['directories','files','immutableAssets','schema','sourceCommit','treeSha256']); if (serverTool) {
assert.equal(manifest.schema, 1); const parity = spawnSync('python3', [serverTool, 'manifest', root, fixture.input.sourceCommit], { encoding: 'utf8' });
assert.equal(manifest.immutableAssets[0], '_astro/entry.abcdefgh.js'); assert.equal(parity.status, 0, `Configured Contabo release tool failed: ${parity.stderr}`);
const expected = createHash('sha256').update(Object.entries(manifest.files).sort(([a], [b]) => a.localeCompare(b)).map(([name, meta]) => `${name}\0${meta.sha256}\n`).join('')).digest('hex'); assert.deepEqual(manifest, JSON.parse(parity.stdout));
assert.equal(manifest.treeSha256, expected); } else {
const serverTool = path.resolve(process.cwd(), '../../contabo/scripts/website-release.py'); t.diagnostic(`Live Contabo-tool parity was not run; this test validates the checked-in schema fixture generated from ${fixture.contaboCommit}. Set AIA_CONTABO_RELEASE_TOOL to the merged website-release.py path for direct parity.`);
const parity = spawnSync('python3', [serverTool, 'manifest', root, 'a'.repeat(40)], { encoding: 'utf8' }); }
assert.equal(parity.status, 0, parity.stderr);
assert.deepEqual(manifest, JSON.parse(parity.stdout));
await fs.mkdir(path.join(root, 'private')); await fs.mkdir(path.join(root, 'private'));
await assert.rejects(releaseManifest(root, 'a'.repeat(40)), /Unexpected private artifact/); await assert.rejects(releaseManifest(root, fixture.input.sourceCommit), /Unexpected private artifact/);
} finally { await fs.rm(root, { recursive: true, force: true }); } } finally { await fs.rm(root, { recursive: true, force: true }); }
}); });
@@ -0,0 +1,32 @@
{
"contract": "AIAMarketing/contabo-vps website-release.py manifest schema",
"contaboCommit": "a421a7b1049244baefa8a6e52efcf97ad2dfa89f",
"input": {
"sourceCommit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"files": {
"index.html": "<title>Synthetic</title>",
"_astro/entry.abcdefgh.js": "export {}"
}
},
"expected": {
"directories": [
"_astro"
],
"files": {
"_astro/entry.abcdefgh.js": {
"sha256": "f4c5cf9bb78e85f15dc27180260637cf24b2a24bc39e0788783a3accc4dde614",
"size": 9
},
"index.html": {
"sha256": "3a958d616a9a1f0d9229cff90395efb5155029a7f31d018c198c0c7d4cf28989",
"size": 24
}
},
"immutableAssets": [
"_astro/entry.abcdefgh.js"
],
"schema": 1,
"sourceCommit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"treeSha256": "4ca2127ce195f1975cbe85fdea800fb3177972afa3a1692794ba403939288694"
}
}
-92
View File
@@ -1,92 +0,0 @@
/** 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 };
+128
View File
@@ -0,0 +1,128 @@
#!/bin/sh
# Root-only Contabo-side installer for a reviewed employee-resource plan.
# It deliberately uses the NixOS base shell/coreutils path, not Node.js.
set -eu
fail() { printf '%s\n' "Employee resource installation stopped: $*" >&2; exit 1; }
ROOT=/srv/www/azinstitute4autism.com
ROLES='staff management'
PATH=/run/current-system/sw/bin:/usr/bin:/bin
# The production SSH command uses env -i, so this hook is reachable only from
# the local synthetic-interface test. It never changes the deployed PATH.
case "${AIA_PORTAL_INSTALL_TEST_PATH:-}" in
'') ;;
*/aia-portal-install-*/fake-bin) PATH="$AIA_PORTAL_INSTALL_TEST_PATH:$PATH" ;;
*) fail 'Invalid synthetic installer test path' ;;
esac
export PATH
safe_name() {
value=$1
case "$value" in ''|.|..|.*|*[!A-Za-z0-9._-]*) return 1 ;; esac
[ "${#value}" -le 241 ]
}
valid_hex() {
value=$1 length=$2
[ "${#value}" -eq "$length" ] && printf '%s' "$value" | grep -Eq '^[0-9a-f]+$'
}
regular_file() { [ -f "$1" ] && [ ! -L "$1" ] && [ "$(stat -c %h "$1")" -eq 1 ]; }
digest() {
result=$(sha256sum "$1") || return 1
printf '%s\n' "${result%% *}"
}
cleanup() {
status=$?
[ -z "${temporary:-}" ] || rm -rf "$temporary"
exit "$status"
}
trap cleanup EXIT HUP INT TERM
[ "$(id -u)" -eq 0 ] || fail 'Resource installation requires root'
[ "$#" -eq 2 ] || fail 'Usage: portal-resource-install.sh STAGING_DIRECTORY REVIEWED_PLAN.tsv'
stage=$1
plan_file=$2
regular_file "$plan_file" || fail 'Resource plan must be a bounded regular file'
[ "$(stat -c %s "$plan_file")" -le 2000000 ] || fail 'Resource plan is too large'
IFS= read -r header < "$plan_file" || fail 'Resource plan is empty'
[ "$header" = 'AIA_PORTAL_RESOURCE_PLAN_V1' ] || fail 'Unsupported resource plan schema'
commit_line=$(sed -n '2p' "$plan_file")
[ -n "$commit_line" ] || fail 'Resource plan has no source commit'
case "$commit_line" in sourceCommit=*) source_commit=${commit_line#sourceCommit=} ;; *) fail 'Resource plan source commit is invalid' ;; esac
valid_hex "$source_commit" 40 || fail 'Resource plan source commit is invalid'
expected_stage="$ROOT/staging/portal-resources-$source_commit"
[ "$(readlink -f -- "$stage")" = "$expected_stage" ] || fail 'Resource stage is outside the approved root-controlled staging directory'
[ -d "$stage" ] && [ ! -L "$stage" ] || fail 'Resource stage is unsafe'
temporary=$(mktemp -d)
destinations="$temporary/destinations"
staged="$temporary/staged"
: > "$destinations"
: > "$staged"
sed -n '3,$p' "$plan_file" > "$temporary/records"
count=0
line=2
tab=$(printf '\t')
while IFS= read -r record || [ -n "$record" ]; do
line=$((line + 1))
[ -n "$record" ] || fail "Empty resource plan line $line"
IFS="$tab" read -r kind role name stage_name resource_hash size extra <<EOF_RECORD
$record
EOF_RECORD
[ "$kind" = file ] && [ -z "${extra:-}" ] || fail "Invalid resource plan line $line"
case "$role" in staff|management) ;; *) fail "Invalid resource role on line $line" ;; esac
safe_name "$name" && safe_name "$stage_name" || fail "Unsafe resource path on line $line"
valid_hex "$resource_hash" 64 || fail "Invalid resource digest on line $line"
case "$size" in ''|*[!0-9]*) fail "Invalid resource size on line $line" ;; esac
[ "$size" -gt 0 ] && [ "$size" -le 52428800 ] || fail "Invalid resource size on line $line"
destination="$role/$name"
staged_name="$role/$stage_name"
grep -Fqx -- "$destination" "$destinations" && fail "Duplicate resource destination"
grep -Fqx -- "$staged_name" "$staged" && fail "Duplicate staged resource"
printf '%s\n' "$destination" >> "$destinations"
printf '%s\n' "$staged_name" >> "$staged"
count=$((count + 1))
done < "$temporary/records"
[ "$count" -gt 0 ] || fail 'Resource plan has no files'
for role in $ROLES; do
directory="$stage/$role"
[ -d "$directory" ] && [ ! -L "$directory" ] || fail "Missing safe $role staging directory"
find "$directory" -mindepth 1 -print > "$temporary/actual-$role"
while IFS= read -r source; do
relative=${source#"$stage/"}
grep -Fqx -- "$relative" "$staged" || fail "Unexpected staged resource: $relative"
regular_file "$source" || fail "Unsafe staged resource: $relative"
done < "$temporary/actual-$role"
done
# Re-read only validated plan rows, verify every listed input, then install with
# root:nginx ownership and 0640 permissions. mv -n prevents replacement races.
installed=0
while IFS= read -r record || [ -n "$record" ]; do
IFS="$tab" read -r kind role name stage_name resource_hash size extra <<EOF_RECORD
$record
EOF_RECORD
source="$stage/$role/$stage_name"
regular_file "$source" || fail "Missing staged resource: $role/$name"
[ "$(stat -c %s "$source")" -eq "$size" ] && [ "$(digest "$source")" = "$resource_hash" ] || fail "Staged resource digest mismatch: $role/$name"
destination_directory="$ROOT/secure-resources/$role"
destination="$destination_directory/$name"
[ -d "$destination_directory" ] && [ ! -L "$destination_directory" ] || fail "Protected destination directory is unsafe: $role"
if [ -e "$destination" ] || [ -L "$destination" ]; then
regular_file "$destination" || fail "Unsafe existing resource destination: $role/$name"
[ "$(stat -c %s "$destination")" -eq "$size" ] && [ "$(digest "$destination")" = "$resource_hash" ] || fail "Refusing to replace existing resource: $role/$name"
printf '%s\n' "already-verified $role/$name"
installed=$((installed + 1))
continue
fi
next="$destination_directory/.${name}.next-$$"
[ ! -e "$next" ] && [ ! -L "$next" ] || fail "Temporary destination already exists: $role/$name"
install -o root -g nginx -m 0640 "$source" "$next" || fail "Unable to stage protected resource: $role/$name"
mv -n -- "$next" "$destination" || fail "Unable to install protected resource: $role/$name"
regular_file "$destination" && [ "$(stat -c %s "$destination")" -eq "$size" ] && [ "$(digest "$destination")" = "$resource_hash" ] || fail "Installed resource verification failed: $role/$name"
printf '%s\n' "installed $role/$name"
installed=$((installed + 1))
done < "$temporary/records"
printf '%s\n' "sourceCommit=$source_commit installed=$installed"