Files
han-app/VM1_app/codebase/backend/scripts/validate-env
T
2026-08-26 11:05:32 +03:00

296 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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",
"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",
}
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://<private VM2 hostname>: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: ожидается <Safety hostname>=<private VM2 IP>"
)
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())