707 lines
24 KiB
Bash
Executable File
707 lines
24 KiB
Bash
Executable File
#!/bin/bash
|
|
set -euo pipefail
|
|
|
|
if [[ -n "${WNH_PROJECT_ROOT:-}" ]]; then
|
|
ROOT=$(realpath --canonicalize-existing "$WNH_PROJECT_ROOT")
|
|
else
|
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
|
fi
|
|
env_file=${1:-}
|
|
expected_domain=${2:-}
|
|
android_release_mode=${3:-}
|
|
|
|
usage() {
|
|
echo "Usage: $0 ENV_FILE EXPECTED_DOMAIN [--allow-pre-play]" >&2
|
|
}
|
|
|
|
if [[ -z "$env_file" || -z "$expected_domain" ]]; then
|
|
usage
|
|
exit 1
|
|
fi
|
|
|
|
if [[ -n "$android_release_mode" &&
|
|
"$android_release_mode" != "--allow-pre-play" ]]; then
|
|
usage
|
|
exit 1
|
|
fi
|
|
|
|
if [[ ! -f "$env_file" ]]; then
|
|
echo "Deployment environment does not exist: $env_file" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "$(stat -c '%a' "$env_file")" != 600 ]]; then
|
|
echo "Deployment environment must have mode 0600: $env_file" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "$(stat -c '%u' "$env_file")" != "$(id -u)" ]]; then
|
|
echo "Deployment environment must be owned by the current operator." >&2
|
|
exit 1
|
|
fi
|
|
|
|
read_value() {
|
|
local key=$1
|
|
|
|
awk -v key="$key" '
|
|
index($0, key "=") == 1 {
|
|
print substr($0, length(key) + 2)
|
|
found = 1
|
|
}
|
|
END { if (!found) exit 1 }
|
|
' "$env_file"
|
|
}
|
|
|
|
optional_value() {
|
|
local key=$1
|
|
|
|
read_value "$key" 2>/dev/null || true
|
|
}
|
|
|
|
require_value() {
|
|
local key=$1
|
|
local value
|
|
|
|
if ! value=$(read_value "$key") || [[ -z "$value" ]]; then
|
|
echo "$key is missing or empty in $env_file." >&2
|
|
exit 1
|
|
fi
|
|
|
|
printf '%s' "$value"
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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_mailbox_address() {
|
|
local address=$1 local_part domain label
|
|
local -a labels
|
|
|
|
[[ "$address" =~ ^[A-Za-z0-9.!#\$%\&\'*+/=\?\^_\`\{\|\}~-]+@[A-Za-z0-9.-]+$ ]] ||
|
|
return 1
|
|
|
|
local_part=${address%@*}
|
|
domain=${address#*@}
|
|
[[ -n "$local_part" && "$local_part" != .* && "$local_part" != *. &&
|
|
"$local_part" != *..* ]] || return 1
|
|
|
|
IFS='.' read -r -a labels <<<"$domain"
|
|
[[ ${#labels[@]} -ge 2 ]] || return 1
|
|
|
|
for label in "${labels[@]}"; do
|
|
[[ -n "$label" && "$label" =~ ^[A-Za-z0-9-]+$ &&
|
|
"$label" != -* && "$label" != *- ]] || return 1
|
|
done
|
|
}
|
|
|
|
valid_public_rate_limit_policy() {
|
|
local json=$1
|
|
|
|
command -v jq >/dev/null 2>&1 || {
|
|
echo "Required command is unavailable for rate-limit validation: jq" >&2
|
|
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
|
|
}
|
|
|
|
reject_marker() {
|
|
local key=$1
|
|
local value=$2
|
|
|
|
case "$value" in
|
|
*REPLACE* | *GENERATE* | *example.com*)
|
|
echo "$key still contains a template value." >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
phx_host=$(require_value PHX_HOST)
|
|
deployment_target=$(require_value DEPLOYMENT_TARGET)
|
|
deployment_environment=$(require_value DEPLOYMENT_ENV)
|
|
compose_project_name=$(require_value COMPOSE_PROJECT_NAME)
|
|
app_image=$(require_value APP_IMAGE)
|
|
socket_proxy_image=$(require_value SOCKET_PROXY_IMAGE)
|
|
postgis_image=$(require_value POSTGIS_IMAGE)
|
|
app_topology=$(require_value APP_TOPOLOGY)
|
|
database_mode=$(require_value DATABASE_MODE)
|
|
phx_scheme=$(require_value PHX_SCHEME)
|
|
phx_url_port=$(require_value PHX_URL_PORT)
|
|
base_url=$(require_value WNH_BASE_URL)
|
|
debug_base_url=$(require_value WNH_DEBUG_BASE_URL)
|
|
http_bind_address=$(require_value HTTP_BIND_ADDRESS)
|
|
public_edge_enabled=$(require_value PUBLIC_EDGE_ENABLED)
|
|
public_edge_network=$(require_value PUBLIC_EDGE_NETWORK)
|
|
public_route_id=$(require_value PUBLIC_ROUTE_ID)
|
|
public_upstream_name=$(require_value PUBLIC_UPSTREAM_NAME)
|
|
public_upstream_port=$(require_value PUBLIC_UPSTREAM_PORT)
|
|
public_health_path=$(require_value PUBLIC_HEALTH_PATH)
|
|
public_www_redirect=$(require_value PUBLIC_WWW_REDIRECT)
|
|
trusted_proxy_ips=$(optional_value TRAEFIK_TRUSTED_IPS)
|
|
postgres_password=$(optional_value POSTGRES_PASSWORD)
|
|
postgres_db=$(optional_value POSTGRES_DB)
|
|
postgres_user=$(optional_value POSTGRES_USER)
|
|
database_url=$(require_value DATABASE_URL)
|
|
database_socket_dir=$(optional_value DATABASE_SOCKET_DIR)
|
|
secret_key_base=$(require_value SECRET_KEY_BASE)
|
|
handover_secret=$(require_value HANDOVER_SECRET)
|
|
release_cookie=$(require_value RELEASE_COOKIE)
|
|
metrics_token=$(require_value METRICS_TOKEN)
|
|
email_delivery_provider=$(require_value EMAIL_DELIVERY_PROVIDER)
|
|
smtp_relay=$(optional_value SMTP_RELAY)
|
|
smtp_port=$(optional_value SMTP_PORT)
|
|
smtp_username=$(optional_value SMTP_USERNAME)
|
|
smtp_password=$(optional_value SMTP_PASSWORD)
|
|
smtp_auth=$(optional_value SMTP_AUTH)
|
|
smtp_tls=$(optional_value SMTP_TLS)
|
|
smtp_ssl=$(optional_value SMTP_SSL)
|
|
email_from_address=$(require_value EMAIL_FROM_ADDRESS)
|
|
support_inbox_address=$(optional_value SUPPORT_INBOX_ADDRESS)
|
|
support_inbound_recipient=$(optional_value SUPPORT_INBOUND_RECIPIENT)
|
|
support_inbound_webhook_token=$(optional_value SUPPORT_INBOUND_WEBHOOK_TOKEN)
|
|
rate_limit_policies_json=$(require_value RATE_LIMIT_POLICIES_JSON)
|
|
google_oauth_client_id=$(optional_value GOOGLE_OAUTH_CLIENT_ID)
|
|
google_oauth_client_secret=$(optional_value GOOGLE_OAUTH_CLIENT_SECRET)
|
|
google_oauth_authorized_party_ids=$(optional_value GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS)
|
|
web_push_vapid_public_key=$(optional_value WEB_PUSH_VAPID_PUBLIC_KEY)
|
|
web_push_vapid_private_key=$(optional_value WEB_PUSH_VAPID_PRIVATE_KEY)
|
|
web_push_vapid_subject=$(optional_value WEB_PUSH_VAPID_SUBJECT)
|
|
firebase_application_id=$(optional_value WNH_FIREBASE_APPLICATION_ID)
|
|
firebase_api_key=$(optional_value WNH_FIREBASE_API_KEY)
|
|
firebase_project_id=$(optional_value WNH_FIREBASE_PROJECT_ID)
|
|
firebase_sender_id=$(optional_value WNH_FIREBASE_GCM_SENDER_ID)
|
|
fcm_project_id=$(optional_value FCM_PROJECT_ID)
|
|
fcm_service_account_file=$(optional_value FCM_SERVICE_ACCOUNT_FILE)
|
|
fcm_service_account_json_base64=$(optional_value FCM_SERVICE_ACCOUNT_JSON_BASE64)
|
|
android_app_links_package_name=$(optional_value ANDROID_APP_LINKS_PACKAGE_NAME)
|
|
android_app_links_fingerprints=$(optional_value ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS)
|
|
android_play_app_signing_fingerprints=$(optional_value ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS)
|
|
codex_session_id=$(require_value CODEX_SESSION_ID)
|
|
edge_compose_project_name=$(require_value EDGE_COMPOSE_PROJECT_NAME)
|
|
caddy_image=$(require_value CADDY_IMAGE)
|
|
primary_domain=$(require_value PRIMARY_DOMAIN)
|
|
primary_upstream=$(require_value PRIMARY_UPSTREAM)
|
|
test_domain=$(require_value TEST_DOMAIN)
|
|
test_upstream=$(require_value TEST_UPSTREAM)
|
|
|
|
[[ "$deployment_target" == compose ]] || {
|
|
echo "Production Compose validation requires DEPLOYMENT_TARGET=compose." >&2
|
|
exit 1
|
|
}
|
|
[[ "$deployment_environment" == production ]] || {
|
|
echo "Production validation requires DEPLOYMENT_ENV=production." >&2
|
|
exit 1
|
|
}
|
|
[[ "$compose_project_name" =~ ^[a-zA-Z0-9_-]+$ ]] || {
|
|
echo "COMPOSE_PROJECT_NAME contains unsupported characters." >&2
|
|
exit 1
|
|
}
|
|
[[ "$compose_project_name" == who_need_help_production ]] || {
|
|
echo "Production must use COMPOSE_PROJECT_NAME=who_need_help_production." >&2
|
|
exit 1
|
|
}
|
|
[[ "$app_image" == who-need-help:production-* ]] || {
|
|
echo "Production must use a production-specific APP_IMAGE." >&2
|
|
exit 1
|
|
}
|
|
[[ "$socket_proxy_image" == who-need-help:socket-proxy-production-* ]] || {
|
|
echo "Production must use a production-specific socket-proxy image." >&2
|
|
exit 1
|
|
}
|
|
[[ "$postgis_image" == who-need-help:postgis-production-* ]] || {
|
|
echo "Production must use a production-specific PostGIS image." >&2
|
|
exit 1
|
|
}
|
|
[[ "$app_topology" =~ ^(compact|split)$ ]] || {
|
|
echo "APP_TOPOLOGY must be compact or split." >&2
|
|
exit 1
|
|
}
|
|
[[ "$database_mode" =~ ^(container|external)$ ]] || {
|
|
echo "DATABASE_MODE must be container or external." >&2
|
|
exit 1
|
|
}
|
|
[[ "$public_edge_enabled" =~ ^(true|false)$ ]] || {
|
|
echo "PUBLIC_EDGE_ENABLED must be true or false." >&2
|
|
exit 1
|
|
}
|
|
if [[ "$public_edge_enabled" == true ]]; then
|
|
[[ "$public_edge_network" =~ ^[a-zA-Z0-9_-]+$ ]] || {
|
|
echo "PUBLIC_EDGE_NETWORK contains unsupported characters." >&2
|
|
exit 1
|
|
}
|
|
[[ "$public_upstream_name" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || {
|
|
echo "PUBLIC_UPSTREAM_NAME must be a lowercase Docker DNS alias." >&2
|
|
exit 1
|
|
}
|
|
[[ "$public_route_id" =~ ^[a-z0-9][a-z0-9_-]{0,62}$ ]] || {
|
|
echo "PUBLIC_ROUTE_ID contains unsupported characters." >&2
|
|
exit 1
|
|
}
|
|
if ! [[ "$public_upstream_port" =~ ^[0-9]+$ ]] ||
|
|
((public_upstream_port < 1 || public_upstream_port > 65535)); then
|
|
echo "PUBLIC_UPSTREAM_PORT must be between 1 and 65535." >&2
|
|
exit 1
|
|
fi
|
|
[[ "$public_health_path" =~ ^/[A-Za-z0-9._~:@%/+,=-]*$ ]] || {
|
|
echo "PUBLIC_HEALTH_PATH must be one absolute path." >&2
|
|
exit 1
|
|
}
|
|
[[ "$public_www_redirect" == true || "$public_www_redirect" == false ]] || {
|
|
echo "PUBLIC_WWW_REDIRECT must be true or false." >&2
|
|
exit 1
|
|
}
|
|
fi
|
|
|
|
[[ "$phx_host" == "$expected_domain" ]] || {
|
|
echo "PHX_HOST does not match EXPECTED_DOMAIN." >&2
|
|
exit 1
|
|
}
|
|
[[ "$phx_scheme" == https && "$phx_url_port" == 443 ]] || {
|
|
echo "Production PHX_SCHEME/PHX_URL_PORT must describe the public HTTPS origin." >&2
|
|
exit 1
|
|
}
|
|
[[ "$base_url" == "https://$expected_domain" ]] || {
|
|
echo "WNH_BASE_URL does not match the public HTTPS origin." >&2
|
|
exit 1
|
|
}
|
|
[[ "$debug_base_url" == "$base_url" ]] || {
|
|
echo "WNH_DEBUG_BASE_URL and WNH_BASE_URL must use the same deployment origin." >&2
|
|
exit 1
|
|
}
|
|
|
|
for pair in \
|
|
"HTTP_BIND_ADDRESS:$http_bind_address" \
|
|
"DATABASE_URL:$database_url" \
|
|
"SECRET_KEY_BASE:$secret_key_base" \
|
|
"HANDOVER_SECRET:$handover_secret" \
|
|
"RELEASE_COOKIE:$release_cookie" \
|
|
"METRICS_TOKEN:$metrics_token" \
|
|
"EMAIL_FROM_ADDRESS:$email_from_address"
|
|
do
|
|
reject_marker "${pair%%:*}" "${pair#*:}"
|
|
done
|
|
|
|
if [[ "$app_topology" == split ]]; then
|
|
[[ -n "$trusted_proxy_ips" ]] || {
|
|
echo "TRAEFIK_TRUSTED_IPS is required for APP_TOPOLOGY=split." >&2
|
|
exit 1
|
|
}
|
|
reject_marker TRAEFIK_TRUSTED_IPS "$trusted_proxy_ips"
|
|
fi
|
|
|
|
case "$database_url" in
|
|
ecto://*) ;;
|
|
*) echo "DATABASE_URL must start with ecto://." >&2; exit 1 ;;
|
|
esac
|
|
|
|
if [[ "$database_mode" == container ]]; then
|
|
[[ -n "$postgres_db" && -n "$postgres_user" ]] || {
|
|
echo "POSTGRES_DB and POSTGRES_USER are required for DATABASE_MODE=container." >&2
|
|
exit 1
|
|
}
|
|
[[ -n "$postgres_password" ]] || {
|
|
echo "POSTGRES_PASSWORD is required for DATABASE_MODE=container." >&2
|
|
exit 1
|
|
}
|
|
reject_marker POSTGRES_PASSWORD "$postgres_password"
|
|
|
|
expected_database_url="ecto://$postgres_user:$postgres_password@db/$postgres_db"
|
|
[[ "$database_url" == "$expected_database_url" ]] || {
|
|
echo "Container DATABASE_URL does not match the generated PostgreSQL role/password/database." >&2
|
|
exit 1
|
|
}
|
|
else
|
|
database_authority=${database_url#ecto://}
|
|
database_authority=${database_authority%%/*}
|
|
database_host_port=${database_authority##*@}
|
|
if [[ "$database_host_port" == db || "$database_host_port" == db:* ]]; then
|
|
echo "External DATABASE_URL still targets the Compose db service." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ -n "$database_socket_dir" ]]; then
|
|
[[ "$database_socket_dir" == /* ]] || {
|
|
echo "DATABASE_SOCKET_DIR must be an absolute path." >&2
|
|
exit 1
|
|
}
|
|
reject_marker DATABASE_SOCKET_DIR "$database_socket_dir"
|
|
fi
|
|
fi
|
|
if [[ "$database_mode" != external && -n "$database_socket_dir" ]]; then
|
|
echo "DATABASE_SOCKET_DIR is only valid for DATABASE_MODE=external." >&2
|
|
exit 1
|
|
fi
|
|
|
|
case "$email_delivery_provider" in
|
|
smtp)
|
|
[[ -n "$smtp_relay" ]] || {
|
|
echo "SMTP_RELAY is required when EMAIL_DELIVERY_PROVIDER=smtp." >&2
|
|
exit 1
|
|
}
|
|
reject_marker SMTP_RELAY "$smtp_relay"
|
|
[[ "$smtp_relay" != mailpit ]] || {
|
|
echo "SMTP_RELAY still targets local Mailpit; public registration needs a transactional relay." >&2
|
|
exit 1
|
|
}
|
|
[[ "$smtp_port" =~ ^[0-9]+$ ]] || {
|
|
echo "SMTP_PORT must be numeric." >&2
|
|
exit 1
|
|
}
|
|
[[ "$smtp_auth" =~ ^(always|never|if_available)$ ]] || {
|
|
echo "SMTP_AUTH has an unsupported value." >&2
|
|
exit 1
|
|
}
|
|
[[ "$smtp_tls" =~ ^(always|never|if_available)$ ]] || {
|
|
echo "SMTP_TLS has an unsupported value." >&2
|
|
exit 1
|
|
}
|
|
[[ "$smtp_ssl" =~ ^(true|false|0|1)$ ]] || {
|
|
echo "SMTP_SSL has an unsupported value." >&2
|
|
exit 1
|
|
}
|
|
if [[ -n "$smtp_username" || -n "$smtp_password" ]]; then
|
|
[[ -n "$smtp_username" && -n "$smtp_password" ]] || {
|
|
echo "SMTP username and password must either both be set or both be empty." >&2
|
|
exit 1
|
|
}
|
|
fi
|
|
if [[ "$smtp_auth" == always && (-z "$smtp_username" || -z "$smtp_password") ]]; then
|
|
echo "SMTP username and password are required when SMTP_AUTH is always." >&2
|
|
exit 1
|
|
fi
|
|
if [[ "$smtp_ssl" =~ ^(true|1)$ && "$smtp_tls" != never ]]; then
|
|
echo "SMTP_TLS must be never when SMTP_SSL enables an implicit TLS connection." >&2
|
|
exit 1
|
|
fi
|
|
;;
|
|
*)
|
|
echo "EMAIL_DELIVERY_PROVIDER must be smtp." >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
valid_mailbox_address "$email_from_address" || {
|
|
echo "EMAIL_FROM_ADDRESS must be a single SMTP-safe mailbox address." >&2
|
|
exit 1
|
|
}
|
|
if [[ -n "$support_inbox_address" ]] &&
|
|
! valid_mailbox_address "$support_inbox_address"; then
|
|
echo "SUPPORT_INBOX_ADDRESS must be a single SMTP-safe mailbox address." >&2
|
|
exit 1
|
|
fi
|
|
if [[ -n "$support_inbound_recipient" ]] &&
|
|
! valid_mailbox_address "$support_inbound_recipient"; then
|
|
echo "SUPPORT_INBOUND_RECIPIENT must be a single SMTP-safe mailbox address." >&2
|
|
exit 1
|
|
fi
|
|
if [[ -n "$support_inbound_recipient" || -n "$support_inbound_webhook_token" ]]; then
|
|
[[ -n "$support_inbound_recipient" && -n "$support_inbound_webhook_token" ]] || {
|
|
echo "SUPPORT_INBOUND_RECIPIENT and SUPPORT_INBOUND_WEBHOOK_TOKEN must be set together." >&2
|
|
exit 1
|
|
}
|
|
reject_marker SUPPORT_INBOUND_WEBHOOK_TOKEN "$support_inbound_webhook_token"
|
|
fi
|
|
|
|
valid_public_rate_limit_policy "$rate_limit_policies_json" || {
|
|
echo "RATE_LIMIT_POLICIES_JSON must enable every documented public authentication and intake policy with positive integer limit and window_seconds values; {} is reserved for isolated tests." >&2
|
|
exit 1
|
|
}
|
|
|
|
if [[ -n "$google_oauth_client_id" || -n "$google_oauth_client_secret" ]]; then
|
|
[[ -n "$google_oauth_client_id" && -n "$google_oauth_client_secret" ]] || {
|
|
echo "Google OAuth client ID and secret must either both be set or both be empty." >&2
|
|
exit 1
|
|
}
|
|
|
|
reject_marker GOOGLE_OAUTH_CLIENT_ID "$google_oauth_client_id"
|
|
reject_marker GOOGLE_OAUTH_CLIENT_SECRET "$google_oauth_client_secret"
|
|
fi
|
|
if [[ -n "$google_oauth_authorized_party_ids" ]]; then
|
|
[[ -n "$google_oauth_client_id" && -n "$google_oauth_client_secret" ]] || {
|
|
echo "Android Google authorized parties require the Google OAuth client." >&2
|
|
exit 1
|
|
}
|
|
|
|
reject_marker GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS "$google_oauth_authorized_party_ids"
|
|
|
|
valid_nonempty_csv "$google_oauth_authorized_party_ids" || {
|
|
echo "GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS must be a non-empty CSV list." >&2
|
|
exit 1
|
|
}
|
|
fi
|
|
|
|
firebase_values=(
|
|
"$firebase_application_id"
|
|
"$firebase_api_key"
|
|
"$firebase_project_id"
|
|
"$firebase_sender_id"
|
|
)
|
|
firebase_nonempty=0
|
|
for candidate in "${firebase_values[@]}"; do
|
|
[[ -z "$candidate" ]] || firebase_nonempty=$((firebase_nonempty + 1))
|
|
done
|
|
if ((firebase_nonempty != 0 && firebase_nonempty != ${#firebase_values[@]})); then
|
|
echo "All four WNH_FIREBASE_* Android client values must be configured together." >&2
|
|
exit 1
|
|
fi
|
|
if ((firebase_nonempty == ${#firebase_values[@]})); then
|
|
firebase_prefix="1:$firebase_sender_id:android:"
|
|
[[ "$firebase_sender_id" =~ ^[0-9]+$ &&
|
|
"$firebase_application_id" == "$firebase_prefix"* &&
|
|
-n "${firebase_application_id#"$firebase_prefix"}" ]] || {
|
|
echo "WNH_FIREBASE_APPLICATION_ID does not belong to WNH_FIREBASE_GCM_SENDER_ID." >&2
|
|
exit 1
|
|
}
|
|
fi
|
|
|
|
vapid_values=(
|
|
"$web_push_vapid_public_key"
|
|
"$web_push_vapid_private_key"
|
|
"$web_push_vapid_subject"
|
|
)
|
|
vapid_nonempty=0
|
|
for candidate in "${vapid_values[@]}"; do
|
|
[[ -z "$candidate" ]] || vapid_nonempty=$((vapid_nonempty + 1))
|
|
done
|
|
if ((vapid_nonempty != 0 && vapid_nonempty != ${#vapid_values[@]})); then
|
|
echo "All three WEB_PUSH_VAPID_* values must be configured together." >&2
|
|
exit 1
|
|
fi
|
|
if ((vapid_nonempty == ${#vapid_values[@]})) &&
|
|
[[ ! "$web_push_vapid_subject" =~ ^(mailto:|https://) ]]; then
|
|
echo "WEB_PUSH_VAPID_SUBJECT must start with mailto: or https://." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ -n "$fcm_service_account_file" && -n "$fcm_service_account_json_base64" ]]; then
|
|
echo "Set only one FCM service-account credential source." >&2
|
|
exit 1
|
|
fi
|
|
if [[ -n "$fcm_project_id" || -n "$fcm_service_account_file" ||
|
|
-n "$fcm_service_account_json_base64" ]]; then
|
|
[[ -n "$fcm_project_id" ]] || {
|
|
echo "FCM_PROJECT_ID is required with FCM credentials." >&2
|
|
exit 1
|
|
}
|
|
[[ -n "$fcm_service_account_file" || -n "$fcm_service_account_json_base64" ]] || {
|
|
echo "One FCM service-account credential source is required with FCM_PROJECT_ID." >&2
|
|
exit 1
|
|
}
|
|
|
|
fcm_credential_project_id=
|
|
if [[ -n "$fcm_service_account_file" ]]; then
|
|
command -v jq >/dev/null 2>&1 || {
|
|
echo "Required command is unavailable for FCM validation: jq" >&2
|
|
exit 1
|
|
}
|
|
[[ "$fcm_service_account_file" == /* && -r "$fcm_service_account_file" ]] || {
|
|
echo "FCM_SERVICE_ACCOUNT_FILE must be an absolute readable file." >&2
|
|
exit 1
|
|
}
|
|
valid_fcm_service_account_json <"$fcm_service_account_file" || {
|
|
echo "FCM_SERVICE_ACCOUNT_FILE is not a complete service-account JSON document." >&2
|
|
exit 1
|
|
}
|
|
fcm_credential_project_id=$(jq --raw-output '.project_id' "$fcm_service_account_file")
|
|
else
|
|
for command in base64 jq; do
|
|
command -v "$command" >/dev/null 2>&1 || {
|
|
echo "Required command is unavailable for FCM validation: $command" >&2
|
|
exit 1
|
|
}
|
|
done
|
|
fcm_decoded_json=$(
|
|
printf '%s' "$fcm_service_account_json_base64" |
|
|
base64 --decode 2>/dev/null
|
|
) || {
|
|
echo "FCM_SERVICE_ACCOUNT_JSON_BASE64 is not valid Base64." >&2
|
|
exit 1
|
|
}
|
|
printf '%s' "$fcm_decoded_json" |
|
|
valid_fcm_service_account_json || {
|
|
echo "FCM_SERVICE_ACCOUNT_JSON_BASE64 is not a complete service-account JSON document." >&2
|
|
exit 1
|
|
}
|
|
fcm_credential_project_id=$(printf '%s' "$fcm_decoded_json" | jq --raw-output '.project_id')
|
|
fi
|
|
|
|
[[ "$fcm_credential_project_id" == "$fcm_project_id" ]] || {
|
|
echo "FCM service-account project does not match FCM_PROJECT_ID." >&2
|
|
exit 1
|
|
}
|
|
if ((firebase_nonempty == ${#firebase_values[@]})); then
|
|
[[ "$fcm_project_id" == "$firebase_project_id" ]] || {
|
|
echo "FCM_PROJECT_ID does not match WNH_FIREBASE_PROJECT_ID." >&2
|
|
exit 1
|
|
}
|
|
fi
|
|
fi
|
|
|
|
if [[ -n "$android_app_links_package_name" ||
|
|
-n "$android_app_links_fingerprints" ||
|
|
-n "$android_play_app_signing_fingerprints" ]]; then
|
|
[[ -n "$android_app_links_package_name" &&
|
|
-n "$android_app_links_fingerprints" ]] || {
|
|
echo "Production Android App Links require the package and published fingerprints together." >&2
|
|
exit 1
|
|
}
|
|
if [[ -z "$android_play_app_signing_fingerprints" &&
|
|
"$android_release_mode" != "--allow-pre-play" ]]; then
|
|
echo "Production Android App Links require Play App Signing fingerprints for full release readiness." >&2
|
|
exit 1
|
|
fi
|
|
[[ "$android_app_links_package_name" =~ ^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$ ]] || {
|
|
echo "ANDROID_APP_LINKS_PACKAGE_NAME is not a valid Android application ID." >&2
|
|
exit 1
|
|
}
|
|
[[ "$android_app_links_package_name" == org.whoneedhelp.mobile ]] || {
|
|
echo "Production Android App Links must use org.whoneedhelp.mobile." >&2
|
|
exit 1
|
|
}
|
|
|
|
IFS=',' read -r -a android_fingerprints <<<"$android_app_links_fingerprints"
|
|
[[ ${#android_fingerprints[@]} -gt 0 ]] || {
|
|
echo "ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS is empty." >&2
|
|
exit 1
|
|
}
|
|
for fingerprint in "${android_fingerprints[@]}"; do
|
|
compact_fingerprint=${fingerprint//:/}
|
|
compact_fingerprint=${compact_fingerprint//[[:space:]]/}
|
|
[[ "$compact_fingerprint" =~ ^[0-9A-Fa-f]{64}$ ]] || {
|
|
echo "ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS contains an invalid SHA-256 fingerprint." >&2
|
|
exit 1
|
|
}
|
|
done
|
|
|
|
if [[ -n "$android_play_app_signing_fingerprints" ]]; then
|
|
IFS=',' read -r -a play_fingerprints <<<"$android_play_app_signing_fingerprints"
|
|
[[ ${#play_fingerprints[@]} -gt 0 ]] || {
|
|
echo "ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS is empty." >&2
|
|
exit 1
|
|
}
|
|
for play_fingerprint in "${play_fingerprints[@]}"; do
|
|
compact_play_fingerprint=${play_fingerprint//:/}
|
|
compact_play_fingerprint=${compact_play_fingerprint//[[:space:]]/}
|
|
[[ "$compact_play_fingerprint" =~ ^[0-9A-Fa-f]{64}$ ]] || {
|
|
echo "ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS contains an invalid SHA-256 fingerprint." >&2
|
|
exit 1
|
|
}
|
|
|
|
play_fingerprint_found=false
|
|
for fingerprint in "${android_fingerprints[@]}"; do
|
|
compact_fingerprint=${fingerprint//:/}
|
|
compact_fingerprint=${compact_fingerprint//[[:space:]]/}
|
|
if [[ "${compact_fingerprint^^}" == "${compact_play_fingerprint^^}" ]]; then
|
|
play_fingerprint_found=true
|
|
break
|
|
fi
|
|
done
|
|
[[ "$play_fingerprint_found" == true ]] || {
|
|
echo "Every Play App Signing fingerprint must also be published in ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS." >&2
|
|
exit 1
|
|
}
|
|
done
|
|
fi
|
|
fi
|
|
|
|
case "$codex_session_id" in
|
|
not-configured | copy-the-main-local-codex-session-id)
|
|
echo "CODEX_SESSION_ID must identify the Build Week Codex session." >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
reject_marker CODEX_SESSION_ID "$codex_session_id"
|
|
|
|
[[ "$edge_compose_project_name" =~ ^[a-zA-Z0-9_-]+$ ]] || {
|
|
echo "EDGE_COMPOSE_PROJECT_NAME contains unsupported characters." >&2
|
|
exit 1
|
|
}
|
|
[[ "$edge_compose_project_name" != "$compose_project_name" ]] || {
|
|
echo "The edge and production application must use different Compose project names." >&2
|
|
exit 1
|
|
}
|
|
[[ "$caddy_image" == who-need-help:caddy-production-* ]] || {
|
|
echo "Production must use a production-specific Caddy image." >&2
|
|
exit 1
|
|
}
|
|
[[ "$primary_domain" == "$expected_domain" ]] || {
|
|
echo "PRIMARY_DOMAIN does not match EXPECTED_DOMAIN." >&2
|
|
exit 1
|
|
}
|
|
[[ "$primary_upstream" == "$public_upstream_name:$public_upstream_port" ]] || {
|
|
echo "PRIMARY_UPSTREAM does not target the production application alias." >&2
|
|
exit 1
|
|
}
|
|
[[ "$test_domain" != "$primary_domain" && "$test_upstream" != "$primary_upstream" ]] || {
|
|
echo "Test and production edge routes must be different." >&2
|
|
exit 1
|
|
}
|
|
|
|
secrets=(
|
|
"$secret_key_base"
|
|
"$handover_secret"
|
|
"$release_cookie"
|
|
"$metrics_token"
|
|
)
|
|
|
|
if [[ "$database_mode" == container ]]; then
|
|
secrets+=("$postgres_password")
|
|
fi
|
|
|
|
for ((left = 0; left < ${#secrets[@]}; left++)); do
|
|
for ((right = left + 1; right < ${#secrets[@]}; right++)); do
|
|
if [[ "${secrets[$left]}" == "${secrets[$right]}" ]]; then
|
|
echo "Deployment secrets must be independent." >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
done
|
|
|
|
"$ROOT/scripts/compose.sh" "$env_file" config --quiet
|
|
if [[ "$public_edge_enabled" == true ]]; then
|
|
"$ROOT/scripts/validate-edge-env.sh" "$env_file" >/dev/null
|
|
fi
|
|
|
|
echo "Production environment structure passed validation without printing secrets."
|
|
echo "This does not test DNS, TLS, email-provider availability/delivery, proxy source IPs, or server capacity."
|