333 lines
13 KiB
Bash
333 lines
13 KiB
Bash
#!/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"
|
|
[ -d /var/log/journal ] || fail "persistent journald directory 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 <<EOF
|
|
$ROOT/docker-compose.yml
|
|
$ROOT/deployment/preflight.sh
|
|
$ROOT/deployment/han-stack@.service
|
|
$ROOT/deployment/han-host-otel-collector@.service
|
|
$ROOT/deployment/observability/otel-host-collector.yaml
|
|
$ROOT/deployment/scripts/tls-deploy-hook.sh
|
|
$ROOT/deployment/secrets/han-compose
|
|
$ROOT/deployment/secrets/han-secrets
|
|
EOF
|
|
fi
|
|
|
|
for executable in \
|
|
"$ROOT/deployment/preflight.sh" \
|
|
"$ROOT/deployment/scripts/tls-deploy-hook.sh" \
|
|
"$ROOT/deployment/secrets/han-compose" \
|
|
"$ROOT/deployment/secrets/han-secrets"
|
|
do
|
|
[ -x "$executable" ] || fail "required executable is not executable: $executable"
|
|
if [ -f "$executable" ] && LC_ALL=C grep -q "$(printf '\r')" "$executable"; then
|
|
fail "CRLF is forbidden in executable: $executable"
|
|
fi
|
|
done
|
|
|
|
if [ -f "$ENV_FILE" ]; then
|
|
if grep -Eq '(^|_)(PASSWORD|SECRET|TOKEN|DATABASE_URL|REDIS_URL|PRIVATE_KEY|ACCESS_KEY)=' "$ENV_FILE"; then
|
|
fail ".env contains a secret-shaped assignment"
|
|
fi
|
|
if grep -Eq '=<[^>]+>|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"
|
|
[ -n "$(value HOST_NAME)" ] || fail "HOST_NAME is required for telemetry correlation"
|
|
echo "$(value TELEMETRYGEN_IMAGE)" |
|
|
grep -Eq '^ghcr\.io/.+@sha256:[a-f0-9]{64}$' ||
|
|
fail "TELEMETRYGEN_IMAGE must be pinned by digest"
|
|
[ "$(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
|
|
|
|
host_otel_config="$ROOT/deployment/observability/otel-host-collector.yaml"
|
|
if [ -f "$host_otel_config" ]; then
|
|
grep -Fq 'filelog/docker' "$host_otel_config" ||
|
|
fail "host collector must include Docker filelog"
|
|
grep -Fq 'journald/host' "$host_otel_config" ||
|
|
fail "host collector must include journald allow-list"
|
|
grep -Fq 'filter/allowlist' "$host_otel_config" ||
|
|
fail "host collector must drop non-allowlisted containers"
|
|
grep -Fq 'file_storage' "$host_otel_config" ||
|
|
fail "host collector must persist offsets and exporter queue"
|
|
! grep -Fq '/var/run/docker.sock' "$host_otel_config" ||
|
|
fail "Docker socket access is forbidden"
|
|
fi
|
|
[ -x /usr/local/bin/otelcol-contrib ] ||
|
|
fail "pinned host otelcol-contrib is not installed"
|
|
otel_checksum=/usr/local/share/han-otel/otelcol-contrib.sha256
|
|
otel_version=/usr/local/share/han-otel/otelcol-contrib.version
|
|
[ -f "$otel_checksum" ] || fail "host Collector checksum record is missing"
|
|
[ -f "$otel_version" ] || fail "host Collector version record is missing"
|
|
if [ -f "$otel_checksum" ]; then
|
|
(cd / && sha256sum -c "$otel_checksum") ||
|
|
fail "host Collector binary checksum mismatch"
|
|
fi
|
|
if [ -x /usr/local/bin/otelcol-contrib ] && [ -f "$otel_version" ]; then
|
|
/usr/local/bin/otelcol-contrib --version 2>&1 |
|
|
grep -Fq "$(cat "$otel_version")" ||
|
|
fail "host Collector binary version mismatch"
|
|
fi
|
|
if [ -x /usr/local/bin/otelcol-contrib ] && [ -f "$host_otel_config" ]; then
|
|
/usr/local/bin/otelcol-contrib validate --config="$host_otel_config" ||
|
|
fail "host collector config validation failed"
|
|
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"
|