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.
|
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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 }); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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