193 lines
7.2 KiB
Bash
193 lines
7.2 KiB
Bash
#!/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"
|
|
|
|
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 CLAMAV_IMAGE OTEL_COLLECTOR_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
|
|
|
|
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'
|
|
|
|
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
|
|
|
|
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"
|
|
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"
|