129 lines
6.0 KiB
Bash
Executable File
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"
|