Files
aia-website/www/tools/portal-resource-install.sh
T

129 lines
6.0 KiB
Bash
Executable File

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