who_need_help/scripts/check-environment-readiness.sh

433 lines
15 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
env_file=${1:-"$ROOT/.env"}
mode=${2:-}
if [[ "$env_file" != /* ]]; then
env_file="$ROOT/$env_file"
fi
if [[ "$mode" != "" && "$mode" != "--require-release" &&
"$mode" != "--require-server-release" ]]; then
echo "Usage: $0 [ENV_FILE] [--require-release|--require-server-release]" >&2
exit 2
fi
if [[ ! -f "$env_file" ]]; then
echo "Environment file does not exist: $env_file" >&2
exit 2
fi
if [[ "$(stat -c '%a' "$env_file")" != 600 ]]; then
echo "Environment file must have mode 0600: $env_file" >&2
exit 2
fi
read_value() {
local key=$1
awk -v key="$key" '
index($0, key "=") == 1 {
value = substr($0, length(key) + 2)
if (value ~ /^".*"$/ || value ~ /^'\''.*'\''$/) {
value = substr(value, 2, length(value) - 2)
}
print value
found = 1
exit
}
END { if (!found) exit 1 }
' "$env_file"
}
value() {
read_value "$1" 2>/dev/null || true
}
is_set() {
[[ -n "$(value "$1")" ]]
}
all_set() {
local key
for key in "$@"; do
is_set "$key" || return 1
done
}
all_empty() {
local key
for key in "$@"; do
is_set "$key" && return 1
done
return 0
}
contains_template_marker() {
local observed=$1
[[ "$observed" == *REPLACE* || "$observed" == *GENERATE* ||
"$observed" == *example.com* || "$observed" == *example.invalid* ]]
}
valid_fcm_service_account_json() {
jq -e '
.type == "service_account" and
(.project_id | type == "string" and length > 0) and
(.client_email | type == "string" and length > 0) and
(.private_key | type == "string" and length > 0)
' >/dev/null 2>&1
}
fcm_service_account_project_id() {
jq -er '
select(
.type == "service_account" and
(.project_id | type == "string" and length > 0) and
(.client_email | type == "string" and length > 0) and
(.private_key | type == "string" and length > 0)
)
| .project_id
' 2>/dev/null
}
firebase_client_values_valid() {
local application_id sender_id prefix
application_id=$(value WNH_FIREBASE_APPLICATION_ID)
sender_id=$(value WNH_FIREBASE_GCM_SENDER_ID)
prefix="1:$sender_id:android:"
[[ "$sender_id" =~ ^[0-9]+$ &&
"$application_id" == "$prefix"* &&
-n "${application_id#"$prefix"}" ]]
}
valid_sha256_fingerprint_list() {
local fingerprint compact
local -a fingerprint_list
IFS=',' read -r -a fingerprint_list <<<"$1"
[[ ${#fingerprint_list[@]} -gt 0 ]] || return 1
for fingerprint in "${fingerprint_list[@]}"; do
compact=${fingerprint//:/}
compact=${compact//[[:space:]]/}
[[ "$compact" =~ ^[0-9A-Fa-f]{64}$ ]] || return 1
done
}
valid_nonempty_csv() {
local item compact
local -a items
IFS=',' read -r -a items <<<"$1"
[[ ${#items[@]} -gt 0 ]] || return 1
for item in "${items[@]}"; do
compact=${item//[[:space:]]/}
[[ -n "$compact" ]] || return 1
done
}
valid_public_rate_limit_policy() {
local json=$1
command -v jq >/dev/null 2>&1 || return 1
printf '%s' "$json" | jq -e '
type == "object" and
length > 0 and
([
"registration_email",
"registration_ip",
"magic_link_email",
"magic_link_ip",
"password_login_email",
"password_login_ip",
"email_change_email",
"email_change_ip",
"support_request",
"support_request_ip",
"content_removal_notice",
"content_removal_notice_ip"
] | all(. as $action |
($json[$action] | type == "object") and
($json[$action].limit | type == "number" and floor == . and . > 0) and
($json[$action].window_seconds | type == "number" and floor == . and . > 0)
))
' --argjson json "$json" >/dev/null 2>&1
}
failures=0
warnings=0
ready() {
printf 'READY %-24s %s\n' "$1" "$2"
}
local_only() {
printf 'LOCAL_ONLY %-24s %s\n' "$1" "$2"
warnings=$((warnings + 1))
}
missing() {
printf 'MISSING %-24s %s\n' "$1" "$2"
failures=$((failures + 1))
}
invalid() {
printf 'INVALID %-24s %s\n' "$1" "$2"
failures=$((failures + 1))
}
partial() {
printf 'PARTIAL %-24s %s\n' "$1" "$2"
failures=$((failures + 1))
}
deployment_env=$(value DEPLOYMENT_ENV)
phx_host=$(value PHX_HOST)
phx_scheme=$(value PHX_SCHEME)
phx_port=$(value PHX_URL_PORT)
base_url=$(value WNH_BASE_URL)
debug_base_url=$(value WNH_DEBUG_BASE_URL)
if all_set DEPLOYMENT_ENV PHX_HOST PHX_SCHEME PHX_URL_PORT WNH_BASE_URL WNH_DEBUG_BASE_URL &&
[[ "$base_url" == "$debug_base_url" ]] &&
[[ "$base_url" == "$phx_scheme://$phx_host" ||
"$base_url" == "$phx_scheme://$phx_host:$phx_port" ]] &&
! contains_template_marker "$base_url"; then
ready "public origin" "deployment=$deployment_env; one canonical Android/web origin"
else
invalid "public origin" "DEPLOYMENT_ENV/PHX_*/WNH_*_BASE_URL are incomplete or inconsistent"
fi
if all_set SECRET_KEY_BASE HANDOVER_SECRET RELEASE_COOKIE METRICS_TOKEN; then
ready "application secrets" "four required independent values are present"
else
missing "application secrets" "SECRET_KEY_BASE, HANDOVER_SECRET, RELEASE_COOKIE, METRICS_TOKEN"
fi
smtp_relay=$(value SMTP_RELAY)
email_delivery_provider=$(value EMAIL_DELIVERY_PROVIDER)
email_delivery_provider=${email_delivery_provider:-smtp}
if [[ "$email_delivery_provider" != "smtp" ]]; then
invalid "transactional email" "EMAIL_DELIVERY_PROVIDER must be smtp"
elif ! all_set SMTP_RELAY SMTP_PORT SMTP_AUTH SMTP_TLS SMTP_SSL EMAIL_FROM_ADDRESS; then
missing "transactional email" "SMTP transport and sender fields"
elif [[ "$smtp_relay" == "mailpit" ]]; then
local_only "transactional email" "Mailpit captures messages locally; it cannot deliver public email"
elif [[ "$(value SMTP_AUTH)" == "always" ]] && ! all_set SMTP_USERNAME SMTP_PASSWORD; then
partial "transactional email" "authenticated SMTP requires both username and password"
else
ready "transactional email" "external SMTP transport is configured"
fi
if is_set SUPPORT_INBOX_ADDRESS; then
ready "support reply address" \
"address is configured; inbound DNS and mailbox delivery require a separate test"
else
missing "support reply address" "SUPPORT_INBOX_ADDRESS"
fi
if is_set SUPPORT_INBOUND_RECIPIENT && is_set SUPPORT_INBOUND_WEBHOOK_TOKEN; then
ready "inbound support webhook" \
"authenticated application endpoint is configured; provider webhook and MX delivery still require an external receive test"
elif is_set SUPPORT_INBOUND_RECIPIENT || is_set SUPPORT_INBOUND_WEBHOOK_TOKEN; then
missing "inbound support webhook" \
"SUPPORT_INBOUND_RECIPIENT and SUPPORT_INBOUND_WEBHOOK_TOKEN must be set together"
else
ready "inbound support webhook" \
"optional inbound email intake is disabled"
fi
rate_limit_policies_json=$(value RATE_LIMIT_POLICIES_JSON)
if [[ "$deployment_env" == test && "$rate_limit_policies_json" == "{}" ]]; then
local_only "public rate limits" "all shared counters are disabled for this isolated test deployment"
elif [[ -z "$rate_limit_policies_json" ]]; then
missing "public rate limits" "RATE_LIMIT_POLICIES_JSON"
elif valid_public_rate_limit_policy "$rate_limit_policies_json"; then
ready "public rate limits" "authentication and anonymous-intake policies are enabled"
else
invalid "public rate limits" "required public policies are missing, malformed, or disabled"
fi
if all_empty GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET; then
missing "Google sign-in" "GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET"
elif all_set GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET; then
ready "Google sign-in" "client ID and secret are both configured"
else
partial "Google sign-in" "client ID and secret must be configured together"
fi
if is_set GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS; then
if ! all_set GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET; then
invalid "Android Google sign-in" "authorized parties require the Google OAuth client"
elif valid_nonempty_csv "$(value GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS)"; then
ready "Android Google sign-in" "one or more authorized Android OAuth clients"
else
invalid "Android Google sign-in" "authorized party IDs must be a non-empty CSV list"
fi
else
missing "Android Google sign-in" "GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS"
fi
if all_empty WEB_PUSH_VAPID_PUBLIC_KEY WEB_PUSH_VAPID_PRIVATE_KEY WEB_PUSH_VAPID_SUBJECT; then
missing "browser Web Push" "VAPID public/private keys and subject"
elif all_set WEB_PUSH_VAPID_PUBLIC_KEY WEB_PUSH_VAPID_PRIVATE_KEY WEB_PUSH_VAPID_SUBJECT; then
case "$(value WEB_PUSH_VAPID_SUBJECT)" in
mailto:* | https://*) ready "browser Web Push" "complete VAPID configuration" ;;
*) invalid "browser Web Push" "WEB_PUSH_VAPID_SUBJECT must use mailto: or https://" ;;
esac
else
partial "browser Web Push" "all three VAPID values are required together"
fi
if all_empty WNH_FIREBASE_APPLICATION_ID WNH_FIREBASE_API_KEY \
WNH_FIREBASE_PROJECT_ID WNH_FIREBASE_GCM_SENDER_ID; then
missing "Android Firebase client" "four WNH_FIREBASE_* Android client values"
elif all_set WNH_FIREBASE_APPLICATION_ID WNH_FIREBASE_API_KEY \
WNH_FIREBASE_PROJECT_ID WNH_FIREBASE_GCM_SENDER_ID; then
if firebase_client_values_valid; then
ready "Android Firebase client" "complete internally consistent client configuration"
else
invalid "Android Firebase client" \
"application ID must belong to the numeric configured sender/project number"
fi
else
partial "Android Firebase client" "all four WNH_FIREBASE_* values are required together"
fi
fcm_file=$(value FCM_SERVICE_ACCOUNT_FILE)
fcm_base64=$(value FCM_SERVICE_ACCOUNT_JSON_BASE64)
fcm_project_id=$(value FCM_PROJECT_ID)
firebase_project_id=$(value WNH_FIREBASE_PROJECT_ID)
fcm_credential_project_id=
if [[ -z "$fcm_project_id" && -z "$fcm_file" && -z "$fcm_base64" ]]; then
missing "Android FCM delivery" "FCM project ID and one service-account source"
elif [[ -z "$fcm_project_id" || (-n "$fcm_file" && -n "$fcm_base64") ||
(-z "$fcm_file" && -z "$fcm_base64") ]]; then
partial "Android FCM delivery" "project ID and exactly one credential source are required"
elif [[ -n "$fcm_file" ]]; then
if [[ "$fcm_file" == /* && -r "$fcm_file" ]] &&
fcm_credential_project_id=$(fcm_service_account_project_id <"$fcm_file") &&
[[ "$fcm_credential_project_id" == "$fcm_project_id" ]] &&
[[ -z "$firebase_project_id" || "$fcm_project_id" == "$firebase_project_id" ]]; then
ready "Android FCM delivery" "service account and Android client use the same project"
else
invalid "Android FCM delivery" \
"credential source and configured Firebase/FCM project IDs are incomplete or inconsistent"
fi
elif fcm_credential_project_id=$(
printf '%s' "$fcm_base64" |
base64 --decode 2>/dev/null |
fcm_service_account_project_id
) &&
[[ "$fcm_credential_project_id" == "$fcm_project_id" ]] &&
[[ -z "$firebase_project_id" || "$fcm_project_id" == "$firebase_project_id" ]]; then
ready "Android FCM delivery" "service account and Android client use the same project"
else
invalid "Android FCM delivery" \
"credential source and configured Firebase/FCM project IDs are incomplete or inconsistent"
fi
expected_android_package=
case "$deployment_env" in
development) expected_android_package=org.whoneedhelp.mobile.development ;;
test) expected_android_package=org.whoneedhelp.mobile.staging ;;
production) expected_android_package=org.whoneedhelp.mobile ;;
esac
if all_empty ANDROID_APP_LINKS_PACKAGE_NAME \
ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS \
ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS; then
missing "Android App Links" "package name and signing certificate fingerprint"
elif all_set ANDROID_APP_LINKS_PACKAGE_NAME ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS; then
if [[ -z "$expected_android_package" ||
"$(value ANDROID_APP_LINKS_PACKAGE_NAME)" != "$expected_android_package" ]]; then
invalid "Android App Links" "package does not match DEPLOYMENT_ENV=$deployment_env"
elif ! valid_sha256_fingerprint_list \
"$(value ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS)"; then
invalid "Android App Links" "published signing fingerprints are malformed"
elif [[ "$deployment_env" != production ]]; then
ready "Android App Links" "package and signing fingerprints match this environment"
elif ! is_set ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS; then
if [[ "$mode" == "--require-server-release" ]]; then
ready "Android App Links" \
"pre-Play server release publishes the verified upload certificate"
else
missing "Android App Links" \
"production requires the Play App Signing SHA-256 fingerprint"
fi
elif ! valid_sha256_fingerprint_list \
"$(value ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS)"; then
invalid "Android App Links" "Play App Signing fingerprints are malformed"
else
published_fingerprints=$(value ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS)
play_fingerprints=$(value ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS)
all_play_fingerprints_published=true
IFS=',' read -r -a play_fingerprint_list <<<"$play_fingerprints"
IFS=',' read -r -a published_fingerprint_list <<<"$published_fingerprints"
for play_fingerprint in "${play_fingerprint_list[@]}"; do
compact_play=${play_fingerprint//:/}
compact_play=${compact_play//[[:space:]]/}
play_found=false
for published_fingerprint in "${published_fingerprint_list[@]}"; do
compact_published=${published_fingerprint//:/}
compact_published=${compact_published//[[:space:]]/}
if [[ "${compact_play^^}" == "${compact_published^^}" ]]; then
play_found=true
break
fi
done
if [[ "$play_found" != true ]]; then
all_play_fingerprints_published=false
break
fi
done
if [[ "$all_play_fingerprints_published" == true ]]; then
ready "Android App Links" "published identities include the Play App Signing certificate"
else
invalid "Android App Links" "Play App Signing fingerprint is absent from the published identities"
fi
fi
else
partial "Android App Links" "package and signing fingerprints are incomplete"
fi
android_signing_alias=
android_signing_label=
case "$deployment_env" in
development)
android_signing_alias=WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS
android_signing_label=development
;;
test)
android_signing_alias=WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS
android_signing_label=staging
;;
production)
android_signing_alias=WNH_ANDROID_SIGNING_KEY_ALIAS
android_signing_label=production
;;
esac
if [[ -n "$android_signing_alias" ]] &&
all_set WNH_ANDROID_VERSION_CODE WNH_ANDROID_VERSION_NAME "$android_signing_alias"; then
ready "Android release inputs" \
"version and $android_signing_label signing alias are present"
else
missing "Android release inputs" \
"version code/name and the signing alias for DEPLOYMENT_ENV=$deployment_env"
fi
printf '\nEnvironment readiness: %d blocking item(s), %d local-only warning(s).\n' \
"$failures" "$warnings"
if [[ "$mode" =~ ^--require-(release|server-release)$ &&
($failures -ne 0 || $warnings -ne 0) ]]; then
exit 1
fi