#!/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 <