fix(deploy): make portal installer runtime portable
This commit is contained in:
1 parent
4987f5ee1a
commit
36d417a422
8 files changed
+272
-132
No files matched your search
+4
-3
@@ -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.
|
||||
Use [the release runbook](integrations/nixos/deploy.md) for `npm run deploy`,
|
||||
dry-run, rollback, the encrypted production portal input and the separate
|
||||
one-time employee-resource installer. 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
|
||||
one-time employee-resource installer. The server-side installer is a root-only
|
||||
POSIX shell script using NixOS base tools; it does not require Node.js on
|
||||
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.
|
||||
|
||||
### Google Ads consultation-form conversion handoff
|
||||
|
||||
@@ -61,7 +61,7 @@ npm run deploy:rollback -- FULL_APPROVED_SHA --confirm
|
||||
## Employee resources: separate one-time operation
|
||||
|
||||
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:
|
||||
|
||||
```sh
|
||||
@@ -72,9 +72,11 @@ AIA_DEPLOY_SSH_IDENTITY="$HOME/.ssh/AIAMarketing" \
|
||||
npm run deploy:portal-resources -- --confirm
|
||||
```
|
||||
|
||||
The installer 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
|
||||
The installer invokes the promoted root-only `portal-resource-install.sh` through
|
||||
NixOS's base `/bin/sh` and coreutils path; it does not depend on a globally
|
||||
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
|
||||
directories and refuses to replace a different existing file. The VPS installs
|
||||
regular files as `root:nginx` mode `0640` and reports metadata only.
|
||||
|
||||
@@ -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');
|
||||
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) {
|
||||
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');
|
||||
@@ -69,14 +77,15 @@ async function main() {
|
||||
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-portal-resources-'));
|
||||
try {
|
||||
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 planFile = path.join(temporary, `aia-portal-resources-${sha}.json`);
|
||||
await fs.writeFile(planFile, JSON.stringify(plan, null, 2) + '\n', { mode: 0o600 });
|
||||
const planFile = path.join(temporary, `aia-portal-resources-${sha}.tsv`);
|
||||
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.confirm) fail('Refusing employee-file transfer without --confirm');
|
||||
const host = process.env.AIA_DEPLOY_HOST, identity = process.env.AIA_DEPLOY_SSH_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(' ')}`;
|
||||
run(ssh, [...sshOptions, host, `install -d -m 0700 ${remoteStage}/staff ${remoteStage}/management`]);
|
||||
for (const role of ['staff','management']) {
|
||||
@@ -85,7 +94,7 @@ async function main() {
|
||||
run('rsync', args);
|
||||
}
|
||||
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));
|
||||
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
@@ -3,25 +3,76 @@ import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
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 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 () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-resource-plan-'));
|
||||
const { root, privateRoot, hubfs, stage } = await fixture();
|
||||
try {
|
||||
const hubfs = path.join(root, 'hubfs'), stage = path.join(root, 'stage');
|
||||
await fs.mkdir(hubfs, { recursive: true });
|
||||
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));
|
||||
const { localFile, bytes } = await writeApprovedResource(privateRoot, hubfs);
|
||||
const plan = await prepareResourcePlan(privateRoot, stage, sha);
|
||||
assert.equal(plan.files.length, 1);
|
||||
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 }] }));
|
||||
await assert.rejects(prepareResourcePlan(root, path.join(root, 'second'), 'a'.repeat(40)), /destination/);
|
||||
assert.match(serializeResourcePlan(plan), /^AIA_PORTAL_RESOURCE_PLAN_V1\nsourceCommit=a{40}\nfile\tstaff\tguide\.pdf\t/);
|
||||
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 }); }
|
||||
});
|
||||
+25
-16
@@ -1,30 +1,39 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import test from 'node:test';
|
||||
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-'));
|
||||
try {
|
||||
await fs.mkdir(path.join(root, '_astro'));
|
||||
await fs.writeFile(path.join(root, 'index.html'), '<title>Synthetic</title>');
|
||||
await fs.writeFile(path.join(root, '_astro', 'entry.abcdefgh.js'), 'export {}');
|
||||
const manifest = await releaseManifest(root, 'a'.repeat(40));
|
||||
assert.deepEqual(Object.keys(manifest).sort(), ['directories','files','immutableAssets','schema','sourceCommit','treeSha256']);
|
||||
assert.equal(manifest.schema, 1);
|
||||
assert.equal(manifest.immutableAssets[0], '_astro/entry.abcdefgh.js');
|
||||
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.equal(manifest.treeSha256, expected);
|
||||
const serverTool = path.resolve(process.cwd(), '../../contabo/scripts/website-release.py');
|
||||
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 writeContractArtifact(root);
|
||||
const manifest = await releaseManifest(root, fixture.input.sourceCommit);
|
||||
assert.deepEqual(manifest, fixture.expected);
|
||||
const serverTool = process.env.AIA_CONTABO_RELEASE_TOOL;
|
||||
if (serverTool) {
|
||||
const parity = spawnSync('python3', [serverTool, 'manifest', root, fixture.input.sourceCommit], { encoding: 'utf8' });
|
||||
assert.equal(parity.status, 0, `Configured Contabo release tool failed: ${parity.stderr}`);
|
||||
assert.deepEqual(manifest, JSON.parse(parity.stdout));
|
||||
} else {
|
||||
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.`);
|
||||
}
|
||||
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 }); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
Executable
+128
@@ -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"
|
||||
Reference in new issue
Block a user