Перенесены секреты из .env в SM
This commit is contained in:
@@ -1,46 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail-fast validation for the single HAN Chat deployment environment."""
|
||||
"""Validate public deployment config separately from runtime secrets."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
REQUIRED = {
|
||||
"APP_ENV", "RELEASE_VERSION", "HAN_PG_HOST", "DATABASE_URL",
|
||||
"BITRIX_DATABASE_URL", "BITRIX_SYNC_DATABASE_URL",
|
||||
"MESSAGE_SAFETY_DATABASE_URL", "SMS_DATABASE_URL", "KEYCLOAK_DB_URL", "PUBLIC_HOST",
|
||||
"PUBLIC_WEB_URL", "PUBLIC_API_URL", "PUBLIC_AUTH_URL",
|
||||
"KEYCLOAK_PUBLIC_URL", "KEYCLOAK_INTERNAL_URL", "REDIS_URL",
|
||||
"REDIS_REALTIME_URL", "MESSAGE_SAFETY_REDIS_URL",
|
||||
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",
|
||||
}
|
||||
REQUIRED_RUNTIME = {
|
||||
"DATABASE_URL", "BITRIX_DATABASE_URL", "BITRIX_SYNC_DATABASE_URL",
|
||||
"SMS_DATABASE_URL", "KEYCLOAK_DB_PASSWORD", "REDIS_URL",
|
||||
"REDIS_REALTIME_URL", "MESSAGE_SAFETY_REDIS_URL", "REDIS_API_PASSWORD",
|
||||
"REDIS_SAFETY_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", "BITRIX_SYNC_SERVICE_TOKEN",
|
||||
"KEYCLOAK_SETTINGS_BRIDGE_TOKEN", "KEYCLOAK_OTP_HMAC_KEY",
|
||||
"KEYCLOAK_SMS_SERVICE_URL", "KEYCLOAK_SMS_SERVICE_TOKEN", "SMS_SERVICE_TOKEN",
|
||||
"IDGTL_SMS_BASE_URL", "IDGTL_SMS_API_KEY", "IDGTL_SMS_CALLBACK_PUBLIC_URL",
|
||||
"IDGTL_SMS_CALLBACK_USERNAME", "IDGTL_SMS_CALLBACK_PASSWORD",
|
||||
"KEYCLOAK_ADMIN", "KEYCLOAK_ADMIN_PASSWORD", "CURSOR_HMAC_SECRET",
|
||||
"BITRIX_TOKEN_ENCRYPTION_KEY",
|
||||
"SELECTEL_S3_ENDPOINT_URL",
|
||||
"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",
|
||||
"SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY",
|
||||
"SELECTEL_S3_QUARANTINE_READ_SECRET_KEY", "REDIS_API_PASSWORD",
|
||||
"REDIS_SAFETY_PASSWORD", "REDIS_HEALTH_PASSWORD",
|
||||
}
|
||||
SECRET_KEYS = {
|
||||
key for key in REQUIRED
|
||||
if any(word in key for word in ("TOKEN", "PASSWORD", "SECRET_KEY", "ACCESS_KEY"))
|
||||
} | {
|
||||
OPTIONAL_SECRET_KEYS = {
|
||||
"BITRIX_CLIENT_SECRET", "BITRIX_APPLICATION_TOKEN",
|
||||
"IDGTL_SMS_CALLBACK_USERNAME", "IDGTL_SMS_CALLBACK_PASSWORD",
|
||||
"BITRIX_SYNC_CRM_WEBHOOK_URL", "BITRIX_SYNC_WEBHOOK_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 | {
|
||||
"BITRIX_SYNC_APP_DATABASE_URL", "PG_BACKUP_DSN",
|
||||
}
|
||||
DSN_KEYS = {
|
||||
"DATABASE_URL", "BITRIX_DATABASE_URL", "BITRIX_SYNC_APP_DATABASE_URL",
|
||||
"BITRIX_SYNC_DATABASE_URL", "MESSAGE_SAFETY_DATABASE_URL",
|
||||
"SMS_DATABASE_URL", "PG_BACKUP_DSN",
|
||||
}
|
||||
PLACEHOLDER = re.compile(r"(change-me|example\.(com|ru|invalid)|<[^>]+>)", re.I)
|
||||
|
||||
|
||||
def load(path: Path) -> tuple[dict[str, str], list[str]]:
|
||||
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):
|
||||
@@ -58,33 +68,107 @@ def load(path: Path) -> tuple[dict[str, str], list[str]]:
|
||||
return values, errors
|
||||
|
||||
|
||||
def main() -> int:
|
||||
path = Path(sys.argv[1] if len(sys.argv) > 1 else ".env")
|
||||
if not path.is_file():
|
||||
print(f"ERROR: файл не найден: {path}", file=sys.stderr)
|
||||
return 2
|
||||
env, errors = load(path)
|
||||
for key in sorted(REQUIRED):
|
||||
if not env.get(key):
|
||||
errors.append(f"{key}: обязательное значение отсутствует")
|
||||
for key in sorted(SECRET_KEYS):
|
||||
value = env.get(key, "")
|
||||
if value and (len(value) < 16 or PLACEHOLDER.search(value)):
|
||||
errors.append(f"{key}: секрет должен быть непустым, уникальным и длиной >=16")
|
||||
for key in ("SMS_SERVICE_TOKEN", "KEYCLOAK_SMS_SERVICE_TOKEN"):
|
||||
if env.get(key) and len(env[key]) < 32:
|
||||
errors.append(f"{key}: service token должен иметь длину >=32")
|
||||
captcha_enabled = env.get("KEYCLOAK_YANDEX_CAPTCHA_ENABLED", "false").lower()
|
||||
if captcha_enabled not in {"true", "false"}:
|
||||
errors.append("KEYCLOAK_YANDEX_CAPTCHA_ENABLED: ожидается true или false")
|
||||
if captcha_enabled == "true":
|
||||
for key in (
|
||||
"KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY",
|
||||
"KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY",
|
||||
):
|
||||
if not env.get(key):
|
||||
errors.append(f"{key}: обязательное значение при включённой CAPTCHA отсутствует")
|
||||
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"),
|
||||
("MESSAGE_SAFETY_REDIS_URL", "message_safety", "REDIS_SAFETY_PASSWORD", "/2"),
|
||||
)
|
||||
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")
|
||||
@@ -105,41 +189,6 @@ def main() -> int:
|
||||
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")
|
||||
redis_contract = (
|
||||
("REDIS_URL", "api_backend", "REDIS_API_PASSWORD", "/0"),
|
||||
("REDIS_REALTIME_URL", "api_backend", "REDIS_API_PASSWORD", "/1"),
|
||||
("MESSAGE_SAFETY_REDIS_URL", "message_safety", "REDIS_SAFETY_PASSWORD", "/2"),
|
||||
)
|
||||
for key, username, password_key, database in redis_contract:
|
||||
parsed = urlparse(env.get(key, ""))
|
||||
if (
|
||||
parsed.scheme != "redis"
|
||||
or parsed.hostname != "redis"
|
||||
or parsed.username != username
|
||||
or parsed.password != env.get(password_key)
|
||||
or parsed.path != database
|
||||
):
|
||||
errors.append(f"{key}: ACL user/password/host/DB не согласованы с {password_key}")
|
||||
for key in (
|
||||
"DATABASE_URL", "BITRIX_DATABASE_URL", "BITRIX_SYNC_DATABASE_URL",
|
||||
"MESSAGE_SAFETY_DATABASE_URL", "SMS_DATABASE_URL", "KEYCLOAK_DB_URL",
|
||||
):
|
||||
value = env.get(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; "
|
||||
"используйте database-level search_path роли"
|
||||
)
|
||||
pairs = (
|
||||
("BITRIX_LOCAL_APP_INTERNAL_TOKEN", "BITRIX_INTERNAL_API_TOKEN"),
|
||||
("BITRIX_API_FORWARD_TOKEN", "BITRIX_API_INBOX_TOKEN"),
|
||||
("KEYCLOAK_SMS_SERVICE_TOKEN", "SMS_SERVICE_TOKEN"),
|
||||
)
|
||||
for left, right in pairs:
|
||||
if env.get(left) != env.get(right):
|
||||
errors.append(f"{left} должен совпадать с {right}")
|
||||
try:
|
||||
poll = int(env.get("MESSAGE_SAFETY_TASK_POLL_MAX_SEC", "0"))
|
||||
nginx = int(env.get("NGINX_MESSAGE_READ_TIMEOUT_SEC", "0"))
|
||||
@@ -153,24 +202,55 @@ def main() -> int:
|
||||
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")
|
||||
real_sms = env.get("KEYCLOAK_OTP_MOCK_ENABLED", "").lower() == "false"
|
||||
if production and real_sms and (
|
||||
env.get("IDGTL_SMS_BASE_URL", "").rstrip("/") != "https://direct.i-dgtl.ru"
|
||||
):
|
||||
errors.append("IDGTL_SMS_BASE_URL: production contract требует https://direct.i-dgtl.ru")
|
||||
expected_callback = f"https://{env.get('PUBLIC_HOST', '')}/callbacks/idgtl/sms"
|
||||
if production and env.get("IDGTL_SMS_CALLBACK_PUBLIC_URL") != expected_callback:
|
||||
errors.append(
|
||||
"IDGTL_SMS_CALLBACK_PUBLIC_URL должен совпадать с публичным host и callback path"
|
||||
)
|
||||
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
|
||||
print(f"OK: {path} прошёл проверку ({len(env)} переменных)")
|
||||
scope = "config + runtime secrets" if manifest or use_runtime_env else "non-secret config"
|
||||
print(f"OK: {config_path} прошёл проверку ({scope})")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user