#!/bin/sh set -eu ROOT=${1:-/opt/han-chat/current/backend} ENV_FILE=${2:-/etc/han/vm1.env} MANIFEST=${3:-/run/han-chat/secrets/manifest} failures=0 fail() { echo "FAIL: $*" >&2 failures=$((failures + 1)) } value() { awk -F= -v key="$1" '$1 == key {print substr($0, index($0, "=") + 1)}' "$ENV_FILE" } [ "$(id -u)" -eq 0 ] || fail "preflight must run as root" [ -f "$ROOT/docker-compose.yml" ] || fail "root docker-compose.yml is missing" [ -f "$ENV_FILE" ] || fail ".env is missing" [ -f "$MANIFEST" ] || fail "runtime secret manifest is missing" [ -L /opt/han-chat/current ] || fail "/opt/han-chat/current must be a root-controlled release link" [ "$(/usr/bin/stat -c '%U:%G' /opt/han-chat/current)" = root:root ] || fail "active release link must be root:root" nginx_compose="$ROOT/nginx/docker-compose.yml" [ -f "$nginx_compose" ] || fail "nginx compose fragment is missing" if [ -f "$nginx_compose" ]; then grep -Fq '/var/lib/han-chat/public-tls' "$nginx_compose" && grep -Fq '/run/tls' "$nginx_compose" || fail "nginx must mount host public-tls staging at /run/tls" grep -Fq '/var/lib/han-chat/acme' "$nginx_compose" || fail "nginx must mount host ACME webroot" ! grep -Fq '/etc/letsencrypt' "$nginx_compose" || fail "nginx/certbot compose must not mount the root Certbot tree" ! grep -Eq '^[[:space:]]+certbot:' "$nginx_compose" || fail "certbot must not be a Compose service" ! grep -Eq 'nginx-(certs|acme)' "$nginx_compose" || fail "legacy nginx TLS named volumes are forbidden" fi ! grep -Eq '^[[:space:]]+nginx-(certs|acme)' "$ROOT/docker-compose.yml" || fail "legacy nginx TLS named volumes are forbidden" if [ -d "$ROOT" ]; then if find "$ROOT" -type l -print -quit | grep -q .; then fail "active release contains a symlink" fi while IFS= read -r protected; do [ -f "$protected" ] || { fail "protected deployment file is missing: $protected" continue } [ "$(/usr/bin/stat -c '%U:%G' "$protected")" = root:root ] || fail "$protected must be root:root" mode=$(/usr/bin/stat -c '%A' "$protected") case "$mode" in ??????w???|????????w?) fail "$protected is writable by group/other" ;; esac done <]+>|change-me|example\.(com|org|net)|\.invalid([:/]|$)' "$ENV_FILE"; then fail ".env contains placeholders" fi [ "$(value APP_ENV)" = production ] || fail "APP_ENV must be production" [ "$(value SECRETS_SOURCE)" = selectel ] || fail "production SECRETS_SOURCE must be selectel" [ "$(value FRONTEND_DEV_PROXY_ENABLED)" = false ] || fail "FRONTEND_DEV_PROXY_ENABLED must be false" [ "$(value NGINX_TLS_ENABLED)" = true ] || fail "NGINX_TLS_ENABLED must be true" [ "$(value NGINX_HTTP_PORT)" = 80 ] || fail "nginx must publish host port 80" [ "$(value NGINX_HTTPS_PORT)" = 443 ] || fail "nginx must publish host port 443" [ "$(value NGINX_TLS_CERTIFICATE)" = /run/tls/fullchain.pem ] || fail "nginx certificate must use staged /run/tls/fullchain.pem" [ "$(value NGINX_TLS_CERTIFICATE_KEY)" = /run/tls/privkey.pem ] || fail "nginx key must use staged /run/tls/privkey.pem" safety_url=$(value MESSAGE_SAFETY_URL) echo "$safety_url" | grep -Eq '^https://[A-Za-z0-9.-]+:8443$' || fail "MESSAGE_SAFETY_URL must be private HTTPS VM2 :8443" echo "$safety_url" | grep -Eq '(message-safety|localhost|127\.0\.0\.1)' && fail "MESSAGE_SAFETY_URL must not point to a local/stub service" safety_extra_host=$(value MESSAGE_SAFETY_EXTRA_HOST) if ! python3 - "$safety_url" "$safety_extra_host" <<'PY' import ipaddress import socket import sys from urllib.parse import urlparse host = urlparse(sys.argv[1]).hostname try: mapped_host, mapped_ip = sys.argv[2].rsplit("=", 1) mapped_address = ipaddress.ip_address(mapped_ip) except (IndexError, ValueError): raise SystemExit(1) allowed = ( ipaddress.ip_network("10.0.0.0/8"), ipaddress.ip_network("172.16.0.0/12"), ipaddress.ip_network("192.168.0.0/16"), ipaddress.ip_network("fc00::/7"), ) if ( not host or mapped_host != host or not any(mapped_address in network for network in allowed) ): raise SystemExit(1) try: addresses = { ipaddress.ip_address(item[4][0]) for item in socket.getaddrinfo(host, 8443, type=socket.SOCK_STREAM) } except OSError: raise SystemExit(1) if ( not addresses or mapped_address not in addresses or any(not any(address in network for network in allowed) for address in addresses) ): raise SystemExit(1) PY then fail "MESSAGE_SAFETY_URL hostname must resolve only to private VPC addresses and MESSAGE_SAFETY_EXTRA_HOST must map the same address" fi safety_ca=$(value MESSAGE_SAFETY_CA_HOST_PATH) [ -n "$safety_ca" ] || fail "MESSAGE_SAFETY_CA_HOST_PATH is required" [ -f "$safety_ca" ] || fail "VM2 internal CA file is missing" pg_ca=$(value PG_CA_HOST_PATH) [ -f "$pg_ca" ] || fail "managed PostgreSQL CA file is missing" for image_key in \ API_BACKEND_IMAGE \ BITRIX_LOCAL_APP_IMAGE \ FRONTEND_STATIC_IMAGE \ KEYCLOAK_IMAGE \ NGINX_IMAGE \ OTEL_COLLECTOR_IMAGE \ OTEL_QUEUE_INIT_IMAGE \ REDIS_IMAGE \ SMS_SERVICE_IMAGE \ TOOLBOX_IMAGE do image=$(value "$image_key") echo "$image" | grep -Eq '@sha256:[0-9a-f]{64}$' || fail "$image_key must be pinned by sha256 digest" case "$image" in registry.example.ru/*|*@sha256:0000000000000000000000000000000000000000000000000000000000000000) fail "$image_key still contains the example image/digest" ;; esac done fi resolved_config=$(mktemp) resolved_services=$(mktemp) resolved_images=$(mktemp) trap 'rm -f "$resolved_config" "$resolved_services" "$resolved_images"' EXIT HUP INT TERM if ! command -v docker >/dev/null 2>&1; then fail "docker is required to resolve production Compose" elif [ -f "$ENV_FILE" ]; then if ! docker compose --env-file "$ENV_FILE" -f "$ROOT/docker-compose.yml" \ config >"$resolved_config"; then fail "production Compose does not resolve" else docker compose --env-file "$ENV_FILE" -f "$ROOT/docker-compose.yml" \ config --services >"$resolved_services" || fail "cannot enumerate resolved production services" docker compose --env-file "$ENV_FILE" -f "$ROOT/docker-compose.yml" \ config --images >"$resolved_images" || fail "cannot enumerate resolved production images" while IFS= read -r image; do [ -n "$image" ] || continue echo "$image" | grep -Eq '@sha256:[0-9a-f]{64}$' || fail "resolved production image is not digest-pinned: $image" case "$image" in registry.example.ru/*|*@sha256:0000000000000000000000000000000000000000000000000000000000000000) fail "resolved production image still contains an example value: $image" ;; esac done <"$resolved_images" [ -s "$resolved_images" ] || fail "resolved production image set is empty" for forbidden_service in certbot message-safety bitrix-sync otel-collector-local; do ! grep -Fxq "$forbidden_service" "$resolved_services" || fail "local/legacy service resolved in production: $forbidden_service" done published_services=$( awk ' /^services:$/ { in_services=1; next } in_services && /^[^ ]/ { in_services=0 } in_services && /^ [A-Za-z0-9_.-]+:$/ { service=$1 sub(/:$/, "", service) next } in_services && /^ ports:$/ { print service } ' "$resolved_config" ) [ "$published_services" = nginx ] || fail "only nginx may publish production host ports (found: ${published_services:-none})" fi fi tls_dir=/var/lib/han-chat/public-tls getent group han-nginx-tls | awk -F: '$3 == 11001 {found=1} END {exit !found}' || fail "han-nginx-tls group with GID 11001 is missing" [ "$(/usr/bin/stat -c '%U:%G:%a' "$tls_dir" 2>/dev/null || true)" = root:han-nginx-tls:750 ] || fail "public TLS directory must be root:han-nginx-tls 0750" for tls_file in fullchain.pem privkey.pem; do path="$tls_dir/$tls_file" [ -s "$path" ] || { fail "public TLS file is missing: $path" continue } [ "$(/usr/bin/stat -c '%U:%G:%a' "$path")" = root:han-nginx-tls:640 ] || fail "$path must be root:han-nginx-tls 0640" done if [ -s "$tls_dir/fullchain.pem" ] && [ -s "$tls_dir/privkey.pem" ]; then cert_public=$( openssl x509 -in "$tls_dir/fullchain.pem" -pubkey -noout 2>/dev/null | openssl pkey -pubin -outform DER 2>/dev/null | sha256sum | awk '{print $1}' ) || cert_public= key_public=$( openssl pkey -in "$tls_dir/privkey.pem" -passin pass: -pubout -outform DER 2>/dev/null | sha256sum | awk '{print $1}' ) || key_public= [ -n "$cert_public" ] && [ "$cert_public" = "$key_public" ] || fail "public TLS certificate/private key are invalid or do not match" fi required_secrets=' DATABASE_URL REDIS_URL REDIS_REALTIME_URL MESSAGE_SAFETY_SERVICE_TOKEN BITRIX_DATABASE_URL SMS_DATABASE_URL KEYCLOAK_DB_PASSWORD KEYCLOAK_ADMIN_PASSWORD SELECTEL_S3_ACCESS_KEY SELECTEL_S3_SECRET_KEY OTEL_REMOTE_AUTH_HEADER' if [ -f "$MANIFEST" ]; then old_ifs=$IFS IFS=' ' for name in $required_secrets; do [ -n "$name" ] || continue path=$(awk -F= -v key="$name" '$1 == key {print substr($0, index($0, "=") + 1)}' "$MANIFEST") [ -n "$path" ] || { fail "manifest is missing $name" continue } [ -f "$path" ] || fail "secret file is missing for $name" done IFS=$old_ifs fi if [ -x "$ROOT/scripts/validate-env" ] && [ -f "$ENV_FILE" ] && [ -f "$MANIFEST" ]; then "$ROOT/scripts/validate-env" "$ENV_FILE" --runtime-manifest "$MANIFEST" || fail "config/runtime validator rejected the production inputs" else fail "config/runtime validator or its inputs are unavailable" fi if [ "$failures" -ne 0 ]; then echo "preflight: $failures failure(s); VM1 deployment remains closed" >&2 exit 1 fi echo "preflight: static VM1 production gates passed"