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'), '