diff --git a/www/README.md b/www/README.md index 6f72f76..50bbe6d 100644 --- a/www/README.md +++ b/www/README.md @@ -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 diff --git a/www/integrations/nixos/deploy.md b/www/integrations/nixos/deploy.md index 98f94aa..e398c4d 100644 --- a/www/integrations/nixos/deploy.md +++ b/www/integrations/nixos/deploy.md @@ -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. diff --git a/www/tools/deploy-portal-resources.mjs b/www/tools/deploy-portal-resources.mjs index 30c501b..e059630 100644 --- a/www/tools/deploy-portal-resources.mjs +++ b/www/tools/deploy-portal-resources.mjs @@ -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 }); } } diff --git a/www/tools/deploy-portal-resources.test.mjs b/www/tools/deploy-portal-resources.test.mjs index 919f3a7..615b120 100644 --- a/www/tools/deploy-portal-resources.test.mjs +++ b/www/tools/deploy-portal-resources.test.mjs @@ -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 }); } }); diff --git a/www/tools/deploy.test.mjs b/www/tools/deploy.test.mjs index 824b788..21bd76e 100644 --- a/www/tools/deploy.test.mjs +++ b/www/tools/deploy.test.mjs @@ -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'), 'Synthetic'); - 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 }); } }); diff --git a/www/tools/fixtures/contabo-release-manifest-v1.json b/www/tools/fixtures/contabo-release-manifest-v1.json new file mode 100644 index 0000000..85aba7a --- /dev/null +++ b/www/tools/fixtures/contabo-release-manifest-v1.json @@ -0,0 +1,32 @@ +{ + "contract": "AIAMarketing/contabo-vps website-release.py manifest schema", + "contaboCommit": "a421a7b1049244baefa8a6e52efcf97ad2dfa89f", + "input": { + "sourceCommit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "files": { + "index.html": "Synthetic", + "_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" + } +} diff --git a/www/tools/portal-resource-install.mjs b/www/tools/portal-resource-install.mjs deleted file mode 100644 index 29ee6fc..0000000 --- a/www/tools/portal-resource-install.mjs +++ /dev/null @@ -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 }; diff --git a/www/tools/portal-resource-install.sh b/www/tools/portal-resource-install.sh new file mode 100755 index 0000000..3093cc4 --- /dev/null +++ b/www/tools/portal-resource-install.sh @@ -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 <> "$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 <