#!/bin/sh set -eu ROOT=${1:-/opt/han-chat/services} ENV_FILE=${2:-$ROOT/.env} MANIFEST=${3:-/run/han-chat/secrets/manifest} failures=0 fail() { echo "FAIL: $*" >&2 failures=$((failures + 1)) } [ "$(id -u)" -eq 0 ] || fail "preflight must inspect production files as root" [ -f "$ROOT/docker-compose.yml" ] || fail "root docker-compose.yml is missing" [ -d "$ROOT/message-safety" ] || fail "message-safety artifact directory is missing" [ -d "$ROOT/bitrix-sync" ] || fail "bitrix-sync artifact directory is missing" [ -f "$ENV_FILE" ] || fail ".env is missing" [ -f "$MANIFEST" ] || fail "runtime secret manifest is missing" [ -f /etc/han-chat/message-safety-mode.env ] || fail "root-owned Message Safety mode file is missing; initialize standard mode" /usr/bin/getent group han-message-safety | /usr/bin/awk -F: '$3 == 10001 {found=1} END {exit !found}' || fail "han-message-safety group with GID 10001 is missing" [ -x /opt/kaspersky/kesl/bin/kesl-control ] || fail "KESL 12.4 control utility is missing" /usr/bin/systemctl is-active --quiet kesl.service || fail "kesl.service is not active" /usr/bin/systemctl is-active --quiet han-kesl-scan-broker.socket || fail "KESL scan broker socket is not active" [ "$(/usr/bin/stat -c '%U:%G:%a' /run/han-kesl 2>/dev/null || true)" = \ "root:han-message-safety:750" ] || fail "KESL broker runtime directory must be root:han-message-safety 0750" [ -S /run/han-kesl/scan.sock ] || fail "KESL scan broker Unix socket is missing" if [ -S /run/han-kesl/scan.sock ]; then [ "$(/usr/bin/stat -c '%U:%G:%a' /run/han-kesl/scan.sock)" = \ "root:han-message-safety:660" ] || fail "KESL broker socket must be root:han-message-safety 0660" fi [ -x /usr/local/libexec/han-kesl-scan-broker ] && /usr/local/libexec/han-kesl-scan-broker --probe >/dev/null || fail "KESL scan broker readiness probe failed" public_tls_dir=/var/lib/han-chat/public-tls /usr/bin/getent group han-nginx-tls | /usr/bin/awk -F: '$3 == 11001 {found=1} END {exit !found}' || fail "han-nginx-tls group with GID 11001 is missing" [ -d "$public_tls_dir" ] || fail "public TLS staging directory is missing" for tls_file in fullchain.pem privkey.pem; do path="$public_tls_dir/$tls_file" [ -s "$path" ] || { fail "public TLS file is missing or empty: $path" continue } [ "$(/usr/bin/stat -c '%U:%G:%a' "$path")" = "root:han-nginx-tls:640" ] || fail "public TLS file must be root:han-nginx-tls 0640: $path" done if [ -f "$ENV_FILE" ]; then if /usr/bin/grep -Eq '(^|_)(PASSWORD|SECRET|TOKEN|DATABASE_URL|REDIS_URL|PRIVATE_KEY|ACCESS_KEY)=' "$ENV_FILE"; then fail ".env contains a secret-shaped key" fi if /usr/bin/grep -Eq '=<[^>]+>|change-me|example\.(com|org|net)' "$ENV_FILE"; then fail ".env still contains placeholders" fi bitrix_enabled=$(/usr/bin/awk -F= '$1 == "BITRIX_SYNC_ENABLED" {print $2}' "$ENV_FILE") bitrix_mode=$(/usr/bin/awk -F= '$1 == "BITRIX_SYNC_MODE" {print $2}' "$ENV_FILE") case "$bitrix_enabled" in true|false) ;; *) fail "BITRIX_SYNC_ENABLED must be exactly true or false" ;; esac if { [ "$bitrix_enabled" = true ] && [ "$bitrix_mode" != full ]; } || { [ "$bitrix_enabled" = false ] && [ "$bitrix_mode" != disabled ]; }; then fail "BITRIX_SYNC_MODE must be full when enabled and disabled otherwise" fi otel_tls_insecure=$(/usr/bin/awk -F= \ '$1 == "OTEL_REMOTE_TLS_INSECURE" {print $2}' "$ENV_FILE") case "$otel_tls_insecure" in true|false) ;; *) fail "OTEL_REMOTE_TLS_INSECURE must be exactly true or false" ;; esac for image_key in MESSAGE_SAFETY_IMAGE BITRIX_SYNC_IMAGE NGINX_IMAGE REDIS_IMAGE OTEL_COLLECTOR_IMAGE REDIS_EXPORTER_IMAGE NGINX_EXPORTER_IMAGE; do image=$(/usr/bin/awk -F= -v key="$image_key" '$1 == key {print substr($0, index($0, "=") + 1)}' "$ENV_FILE") echo "$image" | /usr/bin/grep -Eq '@sha256:[0-9a-f]{64}$' || fail "$image_key must be pinned by sha256 digest" done private_bind=$(/usr/bin/awk -F= '$1 == "PROCESSING_PRIVATE_BIND_ADDRESS" {print $2}' "$ENV_FILE") case "$private_bind" in ""|0.0.0.0|::|127.*) fail "private 8443 bind address is unsafe" ;; esac fi collector_config="$ROOT/observability/otel-collector.yaml" [ -f "$collector_config" ] || fail "OpenTelemetry Collector config is missing" if [ -f "$collector_config" ]; then /usr/bin/grep -Fq 'service.namespace, value: han-chat' "$collector_config" || fail "Collector must enforce service.namespace=han-chat" /usr/bin/grep -Fq 'receivers: [otlp, prometheus, hostmetrics]' "$collector_config" || fail "Collector metrics pipeline is incomplete" /usr/bin/grep -Fq 'receivers: [otlp]' "$collector_config" || fail "Collector direct OTLP logs pipeline is missing" ! /usr/bin/grep -Fq 'filelog' "$collector_config" || fail "Collector filelog receiver is forbidden for direct OTLP logging" /usr/bin/grep -Fq 'redis-exporter:9121' "$collector_config" || fail "Collector Redis exporter scrape target is missing" /usr/bin/grep -Fq 'nginx-exporter:9113' "$collector_config" || fail "Collector nginx exporter scrape target is missing" /usr/bin/grep -Fq 'tail_sampling:' "$collector_config" || fail "Collector tail sampling is missing" fi bitrix_allowlist="$ROOT/nginx/allowlists/bitrix-webhook-allowlist.conf" private_allowlist="$ROOT/nginx/allowlists/private-caller-allowlist.conf" for allowlist in "$bitrix_allowlist" "$private_allowlist"; do [ -f "$allowlist" ] || { fail "allow-list is missing: $allowlist" continue } [ "$(/usr/bin/tail -n 1 "$allowlist" | /usr/bin/tr -d '[:space:]')" = "denyall;" ] || fail "allow-list must end in deny all: $allowlist" done if [ "${bitrix_enabled:-}" = true ]; then /usr/bin/grep -Eq '^[[:space:]]*allow[[:space:]]+[^;]+;' "$bitrix_allowlist" || fail "enabled bitrix-sync requires reviewed webhook CIDRs" else ! /usr/bin/grep -Eq '^[[:space:]]*allow[[:space:]]+[^;]+;' "$bitrix_allowlist" || fail "disabled bitrix-sync must keep public webhook allow-list closed" fi /usr/bin/grep -Eq '^[[:space:]]*allow[[:space:]]+[^;]+;' "$private_allowlist" || fail "private 8443 requires reviewed VM1/ops CIDRs" required_secrets=' MESSAGE_SAFETY_DATABASE_URL MESSAGE_SAFETY_CONFIG_ADMIN_DATABASE_URL MESSAGE_SAFETY_REDIS_URL MESSAGE_SAFETY_SERVICE_TOKEN SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY SELECTEL_S3_QUARANTINE_READ_SECRET_KEY VM2_INTERNAL_TLS_CERTIFICATE VM2_INTERNAL_TLS_PRIVATE_KEY BITRIX_SYNC_DATABASE_URL BITRIX_SYNC_MIGRATION_DATABASE_URL BITRIX_SYNC_CRM_REST_WEBHOOK_URL BITRIX_SYNC_CONTACT_RECEIVER_TOKEN BITRIX_SYNC_ALERT_RECEIVER_TOKEN BITRIX_SYNC_SERVICE_TOKEN REDIS_SAFETY_ACL REDIS_EXPORTER_PASSWORD' if [ -f "$MANIFEST" ]; then old_ifs=$IFS IFS=' ' for name in $required_secrets; do [ -n "$name" ] || continue path=$(/usr/bin/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 redis_acl=$(/usr/bin/awk -F= \ '$1 == "REDIS_SAFETY_ACL" {print substr($0, index($0, "=") + 1)}' \ "$MANIFEST") redis_exporter_password_file=$(/usr/bin/awk -F= \ '$1 == "REDIS_EXPORTER_PASSWORD" {print substr($0, index($0, "=") + 1)}' \ "$MANIFEST") if [ -f "$redis_acl" ] && [ -f "$redis_exporter_password_file" ]; then /usr/bin/grep -Eq '^user exporter reset on >[^[:space:]]+ -@all \+ping \+info$' "$redis_acl" || fail "Redis ACL must contain the restricted exporter user" if exporter_password=$(/usr/bin/python3 -c ' import json import sys target = "redis://redis-safety:6379" with open(sys.argv[1], encoding="utf-8") as source: values = json.load(source) if set(values) != {target}: raise SystemExit("password map must contain exactly redis://redis-safety:6379") password = values[target] if not isinstance(password, str) or not password or any(char.isspace() for char in password): raise SystemExit("password must be a non-empty whitespace-free string") print(password, end="") ' "$redis_exporter_password_file"); then /usr/bin/grep -Fq -- ">$exporter_password " "$redis_acl" || fail "Redis exporter password map does not match REDIS_SAFETY_ACL" unset exporter_password else fail "REDIS_EXPORTER_PASSWORD must be a valid redis_exporter JSON password map" fi fi internal_cert=$(/usr/bin/awk -F= \ '$1 == "VM2_INTERNAL_TLS_CERTIFICATE" {print substr($0, index($0, "=") + 1)}' \ "$MANIFEST") internal_key=$(/usr/bin/awk -F= \ '$1 == "VM2_INTERNAL_TLS_PRIVATE_KEY" {print substr($0, index($0, "=") + 1)}' \ "$MANIFEST") cert_valid=false key_valid=false if [ -f "$internal_cert" ] && /usr/bin/openssl x509 -in "$internal_cert" -noout >/dev/null 2>&1; then cert_valid=true else fail "internal TLS certificate is not valid PEM" fi if [ -f "$internal_key" ] && /usr/bin/openssl pkey -in "$internal_key" -passin pass: \ -noout -check >/dev/null 2>&1; then key_valid=true else fail "internal TLS private key is not valid unencrypted PEM" fi if [ "$cert_valid" = true ] && [ "$key_valid" = true ]; then cert_public=$( /usr/bin/openssl x509 -in "$internal_cert" -pubkey -noout | /usr/bin/openssl pkey -pubin -outform DER 2>/dev/null | /usr/bin/sha256sum | /usr/bin/awk '{print $1}' ) key_public=$( /usr/bin/openssl pkey -in "$internal_key" -passin pass: -pubout -outform DER 2>/dev/null | /usr/bin/sha256sum | /usr/bin/awk '{print $1}' ) [ "$cert_public" = "$key_public" ] || fail "internal TLS certificate and private key do not match" fi fi mode_file=/etc/han-chat/message-safety-mode.env if [ -f "$mode_file" ]; then [ "$(/usr/bin/stat -c '%U:%G:%a' "$mode_file")" = root:han-message-safety:640 ] || fail "Message Safety mode file must be root:han-message-safety 0640" mode_lines=$(/usr/bin/sort "$mode_file") case "$mode_lines" in *MESSAGE_SAFETY_MOCK_ENABLED=*MESSAGE_SAFETY_MOCK_FILE_FREE=*MESSAGE_SAFETY_MOCK_TEXT_FREE=*) ;; *) fail "Message Safety mode file is incomplete" ;; esac fi for protected in \ "$ROOT/docker-compose.yml" \ "$ROOT/deployment/han-message-safety-mode" \ "$ROOT/deployment/han-processing.service" \ "$ROOT/deployment/kesl/kesl_scan_broker.py" \ "$ROOT/deployment/kesl/han-kesl-scan-broker.service" \ "$ROOT/deployment/kesl/han-kesl-scan-broker.socket" \ "$ROOT/deployment/kesl/han-kesl-scan-broker.tmpfiles.conf" do [ -f "$protected" ] || continue owner=$(/usr/bin/stat -c '%U:%G' "$protected") [ "$owner" = 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 if [ "$failures" -ne 0 ]; then echo "preflight: $failures failure(s); deployment remains closed" >&2 exit 1 fi echo "preflight: static VM2 gates passed; run compose/nginx/TLS probes separately"