#!/usr/bin/env python3 """Validate public deployment config separately from runtime secrets.""" from __future__ import annotations import ipaddress import os import re import stat import sys from pathlib import Path from urllib.parse import urlparse REQUIRED_CONFIG = { "SECRETS_SOURCE", "APP_ENV", "RELEASE_VERSION", "HAN_PG_HOST", "HAN_PG_PORT", "HAN_PG_DATABASE", "PG_CA_HOST_PATH", "KEYCLOAK_DB_URL", "KEYCLOAK_DB_USERNAME", "PUBLIC_HOST", "PUBLIC_WEB_URL", "HOST_NAME", "PUBLIC_API_URL", "PUBLIC_AUTH_URL", "KEYCLOAK_PUBLIC_URL", "KEYCLOAK_INTERNAL_URL", "KEYCLOAK_REALM", "KEYCLOAK_SMS_SERVICE_URL", "IDGTL_SMS_BASE_URL", "IDGTL_SMS_CALLBACK_PUBLIC_URL", "MESSAGE_SAFETY_URL", "MESSAGE_SAFETY_EXTRA_HOST", "MESSAGE_SAFETY_CA_HOST_PATH", "MESSAGE_SAFETY_API_PREFIX", "OTEL_REMOTE_ENDPOINT", "OTEL_REMOTE_TLS_INSECURE", "TELEMETRYGEN_IMAGE", } REQUIRED_RUNTIME = { "DATABASE_URL", "BITRIX_DATABASE_URL", "SMS_DATABASE_URL", "KEYCLOAK_DB_PASSWORD", "REDIS_URL", "REDIS_REALTIME_URL", "REDIS_API_PASSWORD", "REDIS_HEALTH_PASSWORD", "MESSAGE_SAFETY_SERVICE_TOKEN", "BITRIX_LOCAL_APP_INTERNAL_TOKEN", "BITRIX_INTERNAL_API_TOKEN", "BITRIX_API_FORWARD_TOKEN", "BITRIX_API_INBOX_TOKEN", "KEYCLOAK_SETTINGS_BRIDGE_TOKEN", "KEYCLOAK_OTP_HMAC_KEY", "KEYCLOAK_SMS_SERVICE_TOKEN", "SMS_SERVICE_TOKEN", "IDGTL_SMS_API_KEY", "IDGTL_SMS_CALLBACK_USERNAME", "IDGTL_SMS_CALLBACK_PASSWORD", "KEYCLOAK_ADMIN_PASSWORD", "CURSOR_HMAC_SECRET", "BITRIX_TOKEN_ENCRYPTION_KEY", "SELECTEL_S3_ACCESS_KEY", "SELECTEL_S3_SECRET_KEY", } OPTIONAL_SECRET_KEYS = { "BITRIX_CLIENT_SECRET", "BITRIX_APPLICATION_TOKEN", "KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY", "NOTIFICATIONS_TOKEN_PRODUCER_TEST", "OTEL_REMOTE_AUTH_HEADER", "KEYCLOAK_OTP_MOCK_CODE", } FORBIDDEN_CONFIG_KEYS = REQUIRED_RUNTIME | OPTIONAL_SECRET_KEYS | { "PG_BACKUP_DSN", } DSN_KEYS = { "DATABASE_URL", "BITRIX_DATABASE_URL", "SMS_DATABASE_URL", "PG_BACKUP_DSN", } PLACEHOLDER = re.compile(r"(change-me|example\.(com|ru|invalid)|<[^>]+>)", re.I) def load_env(path: Path) -> tuple[dict[str, str], list[str]]: values: dict[str, str] = {} errors: list[str] = [] for number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): line = raw.strip() if not line or line.startswith("#"): continue if "=" not in line: errors.append(f"строка {number}: ожидается KEY=VALUE") continue key, value = line.split("=", 1) key = key.strip() if key in values: errors.append(f"строка {number}: повтор ключа {key}") values[key] = value.strip() return values, errors def load_manifest(path: Path) -> tuple[dict[str, str], list[str]]: files, errors = load_env(path) values: dict[str, str] = {} for key, raw_path in files.items(): secret_path = Path(raw_path) if not secret_path.is_absolute(): errors.append(f"{key}: manifest должен содержать абсолютный путь") continue try: metadata = secret_path.lstat() mode = stat.S_IMODE(metadata.st_mode) parent_mode = stat.S_IMODE(secret_path.parent.stat().st_mode) if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode): errors.append(f"{key}: secret file должен быть regular file, не symlink") continue if os.name != "nt" and (mode & 0o022 or parent_mode & 0o077): errors.append( f"{key}: secret file или runtime directory имеют небезопасные права" ) continue values[key] = secret_path.read_text(encoding="utf-8").rstrip("\r\n") except OSError: errors.append(f"{key}: secret file недоступен") return values, errors def runtime_from_environment() -> dict[str, str]: keys = REQUIRED_RUNTIME | OPTIONAL_SECRET_KEYS return {key: os.environ[key] for key in keys if key in os.environ} def validate_config(config: dict[str, str], errors: list[str]) -> None: for key in sorted(REQUIRED_CONFIG): if not config.get(key): errors.append(f"{key}: обязательный config отсутствует") if config.get("SECRETS_SOURCE") not in {"selectel", "file"}: errors.append("SECRETS_SOURCE: ожидается selectel или file") for key in sorted(FORBIDDEN_CONFIG_KEYS & config.keys()): errors.append(f"{key}: секрет или credential-bearing DSN запрещён в обычном .env") for key, value in config.items(): if key.endswith("_FILE") and value: errors.append(f"{key}: secret file path задаётся launcher, не обычным .env") if key.endswith("_URL"): parsed = urlparse(value.replace("jdbc:", "", 1)) if parsed.username or parsed.password: errors.append(f"{key}: URL с credentials запрещён в обычном .env") def validate_runtime(config: dict[str, str], runtime: dict[str, str], errors: list[str]) -> None: required = set(REQUIRED_RUNTIME) if config.get("KEYCLOAK_OTP_MOCK_ENABLED", "").lower() == "true": required.add("KEYCLOAK_OTP_MOCK_CODE") if config.get("KEYCLOAK_YANDEX_CAPTCHA_ENABLED", "false").lower() == "true": required.add("KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY") if not config.get("KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY"): errors.append( "KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY: обязательный public config отсутствует" ) for key in sorted(required): value = runtime.get(key, "") if not value: errors.append(f"{key}: обязательный runtime secret отсутствует") elif key == "KEYCLOAK_OTP_MOCK_CODE": if not re.fullmatch(r"\d{6,10}", value) or value == "1234": errors.append( f"{key}: требуется нестандартный цифровой код длиной 6–10 цифр" ) elif key not in DSN_KEYS and (len(value) < 16 or PLACEHOLDER.search(value)): errors.append(f"{key}: runtime secret слишком короткий или является placeholder") for key in DSN_KEYS & runtime.keys(): value = runtime[key] if "sslmode=verify-full" not in value or "sslrootcert=" not in value: errors.append(f"{key}: требуется sslmode=verify-full и sslrootcert") if re.search(r"(?:[?&](?:options|currentSchema)=)", value, re.I): errors.append(f"{key}: options/currentSchema запрещены через PgBouncer") redis_contract = ( ("REDIS_URL", "api_backend", "REDIS_API_PASSWORD", "/0"), ("REDIS_REALTIME_URL", "api_backend", "REDIS_API_PASSWORD", "/1"), ) for key, username, password_key, database in redis_contract: parsed = urlparse(runtime.get(key, "")) if ( parsed.scheme != "redis" or parsed.hostname != "redis" or parsed.username != username or parsed.password != runtime.get(password_key) or parsed.path != database ): errors.append(f"{key}: ACL user/password/host/DB не согласованы с {password_key}") for left, right in ( ("BITRIX_LOCAL_APP_INTERNAL_TOKEN", "BITRIX_INTERNAL_API_TOKEN"), ("BITRIX_API_FORWARD_TOKEN", "BITRIX_API_INBOX_TOKEN"), ("KEYCLOAK_SMS_SERVICE_TOKEN", "SMS_SERVICE_TOKEN"), ): if runtime.get(left) != runtime.get(right): errors.append(f"{left} должен совпадать с {right}") def validate_shared(env: dict[str, str], errors: list[str]) -> None: production = env.get("APP_ENV") in {"production-like", "production"} if production and env.get("FRONTEND_DEV_PROXY_ENABLED", "").lower() != "false": errors.append("FRONTEND_DEV_PROXY_ENABLED: production-like/production требует false") if production and env.get("NGINX_TLS_ENABLED", "").lower() != "true": errors.append("NGINX_TLS_ENABLED: production-like/production требует true") for key in ( "PUBLIC_WEB_URL", "PUBLIC_API_URL", "PUBLIC_AUTH_URL", "KEYCLOAK_PUBLIC_URL", "IDGTL_SMS_BASE_URL", "IDGTL_SMS_CALLBACK_PUBLIC_URL", ): if env.get(key) and urlparse(env[key]).scheme != "https": errors.append(f"{key}: публичный URL должен использовать https") public_origin = f"https://{env.get('PUBLIC_HOST', '')}" expected_public_urls = { "PUBLIC_WEB_URL": public_origin, "PUBLIC_API_URL": f"{public_origin}/api", "PUBLIC_AUTH_URL": f"{public_origin}/auth", "KEYCLOAK_PUBLIC_URL": f"{public_origin}/auth", } for key, expected in expected_public_urls.items(): if env.get(key, "").rstrip("/") != expected: errors.append(f"{key}: должен быть производным от PUBLIC_HOST ({expected})") for key in ( "KEYCLOAK_INTERNAL_URL", "KEYCLOAK_SMS_SERVICE_URL", "BITRIX_LOCAL_APP_BASE_URL", ): parsed = urlparse(env.get(key, "")) if parsed.scheme != "http" or "." in (parsed.hostname or ""): errors.append(f"{key}: ожидается http URL с Docker DNS service name") if not env.get("KEYCLOAK_INTERNAL_URL", "").rstrip("/").endswith("/auth"): errors.append("KEYCLOAK_INTERNAL_URL: внутренний URL должен заканчиваться на /auth") safety_url = env.get("MESSAGE_SAFETY_URL", "").rstrip("/") parsed_safety_url = urlparse(safety_url) try: safety_port = parsed_safety_url.port except ValueError: safety_port = None safety_host = parsed_safety_url.hostname or "" if ( parsed_safety_url.scheme != "https" or safety_port != 8443 or not re.fullmatch(r"[A-Za-z0-9.-]+", safety_host) or safety_host.lower() in {"message-safety", "localhost", "127.0.0.1"} or parsed_safety_url.path or parsed_safety_url.params or parsed_safety_url.query or parsed_safety_url.fragment ): errors.append( "MESSAGE_SAFETY_URL: ожидается remote TLS endpoint " "https://:8443, не local/stub service" ) try: extra_host, extra_ip = env.get("MESSAGE_SAFETY_EXTRA_HOST", "").rsplit("=", 1) address = ipaddress.ip_address(extra_ip) if extra_host != safety_host or not address.is_private: raise ValueError except ValueError: errors.append( "MESSAGE_SAFETY_EXTRA_HOST: ожидается =" ) if env.get("MESSAGE_SAFETY_API_PREFIX") != "/internal/safety/v2": errors.append("MESSAGE_SAFETY_API_PREFIX: ожидается /internal/safety/v2") try: poll = int(env.get("MESSAGE_SAFETY_TASK_POLL_MAX_SEC", "0")) nginx = int(env.get("NGINX_MESSAGE_READ_TIMEOUT_SEC", "0")) if nginx < poll + 30: errors.append("NGINX_MESSAGE_READ_TIMEOUT_SEC должен быть >= poll max + 30") except ValueError: errors.append("Safety/NGINX timeout values должны быть целыми числами") if production and env.get("KEYCLOAK_OTP_MOCK_ENABLED", "").lower() == "true": if env.get("KEYCLOAK_OTP_MOCK_RISK_ACCEPTED", "").lower() != "true": errors.append("KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true обязателен для mock OTP") callback = f"https://{env.get('PUBLIC_HOST', '')}/callbacks/idgtl/sms" if production and env.get("IDGTL_SMS_CALLBACK_PUBLIC_URL") != callback: errors.append("IDGTL_SMS_CALLBACK_PUBLIC_URL должен совпадать с PUBLIC_HOST") if env.get("HAN_PG_HOST") in {"localhost", "127.0.0.1", "postgres", "db"}: errors.append("HAN_PG_HOST: PostgreSQL должен быть внешним managed endpoint") keycloak_dsn = env.get("KEYCLOAK_DB_URL", "") if "sslmode=verify-full" not in keycloak_dsn or "sslrootcert=" not in keycloak_dsn: errors.append("KEYCLOAK_DB_URL: требуется sslmode=verify-full и sslrootcert") def main() -> int: args = sys.argv[1:] config_path = Path(args.pop(0) if args and not args[0].startswith("--") else ".env") manifest: Path | None = None use_runtime_env = False while args: option = args.pop(0) if option == "--runtime-manifest" and args: manifest = Path(args.pop(0)) elif option == "--runtime-env": use_runtime_env = True else: print(f"ERROR: неизвестный параметр {option}", file=sys.stderr) return 2 if not config_path.is_file(): print(f"ERROR: config файл не найден: {config_path}", file=sys.stderr) return 2 config, errors = load_env(config_path) validate_config(config, errors) runtime: dict[str, str] = {} if manifest: if not manifest.is_file(): errors.append("runtime manifest недоступен") else: runtime, manifest_errors = load_manifest(manifest) errors.extend(manifest_errors) elif use_runtime_env: runtime = runtime_from_environment() if manifest or use_runtime_env: validate_runtime(config, runtime, errors) validate_shared({**config, **runtime}, errors) if errors: for error in errors: print(f"ERROR: {error}", file=sys.stderr) return 1 scope = "config + runtime secrets" if manifest or use_runtime_env else "non-secret config" print(f"OK: {config_path} прошёл проверку ({scope})") return 0 if __name__ == "__main__": raise SystemExit(main())