#!/usr/bin/env bash set -euo pipefail # #region docker.backend.entrypoint [C:4] [TYPE Module] [SEMANTICS docker,entrypoint,enterprise,certs] # @BRIEF Container entrypoint — установка корп. сертификатов, bootstrap admin, # затем запуск backend (exec "$@"). # @PRE Python runtime и backend source доступны в /app/backend. # Переменная CERTS_PATH указывает на директорию с .crt файлами (или пусто). # @POST Сертификаты установлены в системное хранилище и NSS базу Chromium. # Admin создан (если INITIAL_ADMIN_CREATE=true). Backend запущен через exec "$@". # @SIDE_EFFECT Модифицирует /usr/local/share/ca-certificates/ и вызывает update-ca-certificates. # Импортирует CA в NSS DB (~/.pki/nssdb) для Chromium. # @LAYER Infrastructure # @RELATION DEPENDS_ON -> [backend/src/scripts/create_admin.py] # @RELATION DEPENDS_ON -> [backend/src/scripts/init_auth_db.py] # @INVARIANT Если CERTS_PATH пуст или нет .crt файлов — install_certificates не падает. # @INVARIANT Существующий admin аккаунт НЕ перезаписывается при перезапуске контейнера # (create_admin.py идемпотентен). # @INVARIANT Повторный запуск install_llm_ca_certs не дублирует сертификаты в bundle. # @INVARIANT Повторный запуск install_ca_to_nss не дублирует сертификаты в NSS DB. # #endregion docker.backend.entrypoint # ====================================================================== # install_certificates — корпоративные CA-сертификаты # ====================================================================== # #region docker.backend.entrypoint.install_certificates [C:3] [TYPE Function] # @BRIEF Устанавливает корпоративные .crt сертификаты из CERTS_PATH в системное хранилище Debian. # @PRE CERTS_PATH указывает на директорию (может быть пуста или не существовать). # В системе установлен пакет ca-certificates. # @POST .crt файлы скопированы в /usr/local/share/ca-certificates/custom/ и # добавлены в системное хранилище через update-ca-certificates. # @SIDE_EFFECT Запускает update-ca-certificates — модифицирует /etc/ssl/certs/. # @LAYER Infrastructure # @RELATION DEPENDS_ON -> [ca-certificates (system package)] # @EXAMPLE: # CERTS_PATH=/opt/certs docker run ... # монтируем ./certs в /opt/certs # В ./certs кладём: my-corporate-ca.crt, another-ca.crt # entrypoint установит оба в доверенные. install_certificates() { local certs_dir="${CERTS_PATH:-/opt/certs}" if [ ! -d "$certs_dir" ]; then echo "[entrypoint] CERTS_PATH=${certs_dir} не существует — пропускаем установку сертификатов" return 0 fi local cert_count cert_count="$(find "$certs_dir" -maxdepth 1 \( -name '*.crt' -o -name '*.pem' \) 2>/dev/null | wc -l)" if [ "$cert_count" -eq 0 ]; then echo "[entrypoint] Нет .crt/.pem файлов в ${certs_dir} — пропускаем установку сертификатов" return 0 fi local target="/usr/local/share/ca-certificates/custom" mkdir -p "$target" echo "[entrypoint] Устанавливаем корпоративные сертификаты из ${certs_dir}..." local copied=0 # nullglob: если файлов нет, цикл не выполняется shopt -s nullglob for cert in "$certs_dir"/*.crt "$certs_dir"/*.pem; do cp -v "$cert" "$target/" 2>&1 | sed 's/^/[entrypoint] /' copied=$((copied + 1)) done shopt -u nullglob if [ "$copied" -eq 0 ]; then echo "[entrypoint] Нет файлов для копирования — пропускаем" return 0 fi echo "[entrypoint] Обновляем системное хранилище CA-сертификатов..." update-ca-certificates --fresh 2>&1 | sed 's/^/[entrypoint] /' echo "[entrypoint] ✅ Установлено ${copied} корпоративных сертификатов" } # #endregion docker.backend.entrypoint.install_certificates # ====================================================================== # install_llm_ca_certs — скачка и установка CA-сертификатов по URL # ====================================================================== # #region docker.backend.entrypoint.install_llm_ca_certs [C:4] [TYPE Function] # @BRIEF Скачивает PEM/DER-сертификаты из LLM_CA_CERT_URLS, конвертирует DER→PEM, # устанавливает в системное хранилище, создаёт хеш-симлинки и при необходимости # добавляет в ca-certificates.crt напрямую (fallback для OpenSSL 3-совместимости). # @RATIONALE DER→PEM конвертация необходима, т.к. update-ca-certificates ожидает # PEM-формат (-----BEGIN CERTIFICATE-----). Корпоративные PKI часто отдают # сертификаты в DER (бинарном) формате через HTTP. # @RATIONALE Хеш-симлинки создаются с поддержкой коллизий (.0, .1, .2...), т.к. # update-ca-certificates в Debian Bookworm иногда пропускает создание ссылок # для сертификатов из поддиректорий. Fingerprint (SHA256) используется для # детекции дубликатов вместо имени файла — защита от ложных срабатываний grep. # @RATIONALE Fallback cat в ca-certificates.crt гарантирует что OpenSSL 3.x # увидит сертификаты даже если update-ca-certificates их пропустит. # Перед добавлением проверяется fingerprint — при повторном запуске # сертификат не дублируется. # @REJECTED Вариант "только контейнерный volume с .crt файлами" отвергнут — # требует ручного копирования сертификатов на хост перед запуском. # URL-based подход позволяет централизованно управлять сертификатами через PKI. # @REJECTED Использование grep по имени файла/серийному номеру для детекции # дубликатов отвергнуто — имя файла может совпадать с содержимым PEM, # а серийный номер не уникален при ротации CA. Fingerprint (SHA256) # является криптографически уникальным идентификатором. # @PRE LLM_CA_CERT_URLS — строка с URL через пробел; curl и openssl установлены. # @POST Сертификаты доступны в /etc/ssl/certs/ca-certificates.crt. # Хеш-симлинки созданы для каждого сертификата (с поддержкой коллизий). # @SIDE_EFFECT Скачивает файлы из внешних URL. Модифицирует /etc/ssl/certs/. # @LAYER Infrastructure # @EXAMPLE: # LLM_CA_CERT_URLS="http://pki.company.com/root.crt http://pki.company.com/intermediate.crt" install_llm_ca_certs() { local urls="${LLM_CA_CERT_URLS:-}" if [ -z "$urls" ]; then echo "[entrypoint] LLM_CA_CERT_URLS не задан — пропускаем скачку сертификатов" return 0 fi local work_dir="/tmp/llm-ca-certs" local target_dir="/usr/local/share/ca-certificates/llm" mkdir -p "$work_dir" "$target_dir" echo "[entrypoint] === Скачивание и установка LLM CA-сертификатов ===" local index=0 for url in $urls; do url="$(echo "$url" | xargs)" # trim [ -z "$url" ] && continue index=$((index + 1)) # Определяем имя файла из URL, или генерируем local filename filename="$(basename "$url" | sed 's/[?#].*//')" if [ -z "$filename" ] || [ "$filename" = "." ] || [ "$filename" = ".." ]; then filename="llm-ca-${index}.crt" fi local raw_file="${work_dir}/${filename}" local pem_file="${target_dir}/$(echo "$filename" | sed 's/\.[^.]*$//').pem" echo "[entrypoint] [${index}] Скачивание: ${url}" # Скачиваем с retry 3 раза local attempt=0 local success=0 while [ $attempt -lt 3 ]; do attempt=$((attempt + 1)) # Сначала пробуем с verify, если не получилось — с --insecure # (chicken-and-egg: PKI сервер может использовать тот же CA, который мы скачиваем) if curl -sS --connect-timeout 10 --max-time 30 -o "$raw_file" "$url" 2>/dev/null; then success=1 break fi # TLS verify failed? Пробуем без проверки (корп. PKI bootstrap) if [ $attempt -eq 1 ]; then echo "[entrypoint] ↻ TLS verify failed, retrying with --insecure..." if curl -sS -k --connect-timeout 10 --max-time 30 -o "$raw_file" "$url" 2>/dev/null; then echo "[entrypoint] ✓ Скачано с --insecure (проверка fingerprint в конце)" success=1 break fi fi echo "[entrypoint] ⚠ Попытка ${attempt}/3 не удалась, повтор через 2с..." sleep 2 done if [ "$success" -eq 0 ]; then echo "[entrypoint] ❌ Не удалось скачать: ${url} — пропускаем" continue fi # Определяем формат: PEM или DER if openssl x509 -in "$raw_file" -inform PEM -noout 2>/dev/null; then echo "[entrypoint] ✓ Формат: PEM" cp "$raw_file" "$pem_file" elif openssl x509 -in "$raw_file" -inform DER -noout 2>/dev/null; then echo "[entrypoint] ↻ Формат: DER — конвертируем в PEM" if ! openssl x509 -in "$raw_file" -inform DER -out "$pem_file" -outform PEM 2>&1; then echo "[entrypoint] ❌ Ошибка конвертации DER→PEM — пропускаем" rm -f "$raw_file" "${pem_file}" continue fi else echo "[entrypoint] ❌ Неизвестный формат (не PEM и не DER) — пропускаем" # Выводим первые 20 байт для диагностики local first_bytes first_bytes="$(head -c 20 "$raw_file" 2>/dev/null | cat -v)" echo "[entrypoint] Первые 20 байт: ${first_bytes}" rm -f "$raw_file" continue fi chmod 644 "$pem_file" echo "[entrypoint] ✅ Установлен: $(basename "$pem_file")" rm -f "$raw_file" done if [ -z "$(ls -A "$target_dir" 2>/dev/null)" ]; then echo "[entrypoint] Нет скачанных сертификатов — пропускаем" rm -rf "$work_dir" return 0 fi # Обновляем системное хранилище echo "[entrypoint] --- Обновление системного хранилища CA-сертификатов ---" update-ca-certificates --fresh 2>&1 | sed 's/^/[entrypoint] /' # Проверяем, попали ли сертификаты в бандл, и создаём хеш-симлинки echo "[entrypoint] --- Валидация установки сертификатов ---" for pem in "$target_dir"/*.pem; do [ -f "$pem" ] || continue local cert_name cert_name="$(basename "$pem" .pem)" local cert_subject cert_subject="$(openssl x509 -in "$pem" -noout -subject 2>/dev/null | head -1 || echo "unknown")" local cert_fingerprint cert_fingerprint="$(openssl x509 -in "$pem" -noout -fingerprint -sha256 2>/dev/null | sed 's/.*=//' || echo "")" # Проверяем есть ли в ca-certificates.crt по fingerprint (криптографически уникален) if [ -n "$cert_fingerprint" ] && \ grep -qF "$cert_fingerprint" /etc/ssl/certs/ca-certificates.crt 2>/dev/null; then echo "[entrypoint] ✅ ${cert_name} — в ca-certificates.crt (${cert_subject})" else echo "[entrypoint] ⚠ ${cert_name} — НЕ в ca-certificates.crt. Добавляем напрямую..." cat "$pem" >> /etc/ssl/certs/ca-certificates.crt echo "[entrypoint] ➕ Добавлен напрямую в ca-certificates.crt" fi # Создаём хеш-симлинку с поддержкой коллизий (.0, .1, .2...) local hash_val hash_val="$(openssl x509 -in "$pem" -noout -hash 2>/dev/null || true)" if [ -n "$hash_val" ]; then local suffix=0 local link_path="/etc/ssl/certs/${hash_val}.${suffix}" # Ищем первый свободный suffix или symlink уже указывающий на наш файл while [ -L "$link_path" ]; do if [ "$(readlink "$link_path")" = "$pem" ]; then break 2 # уже есть, ничего не делаем fi suffix=$((suffix + 1)) link_path="/etc/ssl/certs/${hash_val}.${suffix}" done if [ ! -L "$link_path" ]; then ln -sf "$pem" "$link_path" echo "[entrypoint] 🔗 Создана симлинка: ${hash_val}.${suffix} → ${cert_name}" fi fi done # Проверяем итоговое количество local total_in_bundle total_in_bundle="$(awk -v cmd='openssl x509 -noout -subject' '/BEGIN/{close(cmd)};{print | cmd}' \ < /etc/ssl/certs/ca-certificates.crt 2>/dev/null | wc -l || echo "0")" echo "[entrypoint] ✅ Всего сертификатов в bundle: ${total_in_bundle}" rm -rf "$work_dir" echo "[entrypoint] === Установка LLM CA-сертификатов завершена ===" } # #endregion docker.backend.entrypoint.install_llm_ca_certs # ====================================================================== # bootstrap_admin — создание admin пользователя # ====================================================================== # #region docker.backend.entrypoint.bootstrap_admin [C:2] [TYPE Function] # @BRIEF Создание admin пользователя из переменных окружения (идемпотентно). # @PRE INITIAL_ADMIN_CREATE=true, INITIAL_ADMIN_USERNAME и INITIAL_ADMIN_PASSWORD заданы. # @POST Admin пользователь создан в БД (если ещё не существовал). # @LAYER Infrastructure bootstrap_admin() { local create_flag="${INITIAL_ADMIN_CREATE:-false}" local username="${INITIAL_ADMIN_USERNAME:-}" local password="${INITIAL_ADMIN_PASSWORD:-}" local email="${INITIAL_ADMIN_EMAIL:-}" case "${create_flag,,}" in true|1|yes|y) ;; *) echo "[entrypoint] INITIAL_ADMIN_CREATE disabled — пропускаем bootstrap admin" return 0 ;; esac if [[ -z "${username}" ]]; then echo "[entrypoint] INITIAL_ADMIN_USERNAME required when INITIAL_ADMIN_CREATE=true" >&2 return 1 fi if [[ -z "${password}" ]]; then echo "[entrypoint] INITIAL_ADMIN_PASSWORD required when INITIAL_ADMIN_CREATE=true" >&2 return 1 fi echo "[entrypoint] Initializing auth database..." python3 src/scripts/init_auth_db.py echo "[entrypoint] Creating admin user '${username}' (idempotent)..." if [[ -n "${email}" ]]; then python3 src/scripts/create_admin.py --username "${username}" --password "${password}" --email "${email}" else python3 src/scripts/create_admin.py --username "${username}" --password "${password}" fi echo "[entrypoint] ✅ Admin bootstrap complete" } # #endregion docker.backend.entrypoint.bootstrap_admin # ====================================================================== # install_ca_to_nss — импорт корпоративных CA в NSS базу Chromium # ====================================================================== # #region docker.backend.entrypoint.install_ca_to_nss [C:3] [TYPE Function] # @BRIEF Импортирует PEM-сертификаты из /usr/local/share/ca-certificates/ # в NSS Shared DB (~/.pki/nssdb), чтобы Chromium (Playwright) доверял # корпоративным CA (исправляет ERR_CERT_AUTHORITY_INVALID). # @RATIONALE Playwright's Chromium использует NSS Shared DB (~/.pki/nssdb) # для CA trust, отдельно от системного OpenSSL bundle. update-ca-certificates # добавляет сертификаты в /etc/ssl/certs/, но Chromium их не видит. # certutil -A -t "C,," импортирует PEM как доверенный CA для TLS. # @RATIONALE NSS импорт выполняется после install_llm_ca_certs (скачивает .pem) # и update-ca-certificates (устанавливает в систему). Этот порядок # гарантирует, что PEM-файлы уже есть на диске до создания NSS DB. # @RATIONALE Для дедупликации используется SHA256 fingerprint сертификата, # а не nickname — nickname может совпадать для файлов из разных директорий. # Nickname формируется как "llm-имя" или "custom-имя" для избежания коллизий. # @REJECTED Использование Playwright --ignore-certificate-errors отвергнуто — # отключает ВСЕ SSL проверки глобально, а не только для корп. CA. # @REJECTED Nickname-only дедупликация отвергнута — при разных файлах # с одинаковым именем из разных директорий второй молча пропускается. # @PRE certutil (libnss3-tools) установлен. PEM-файлы есть в target_dirs. # @POST Сертификаты добавлены в NSS базу Chromium с trust-атрибутами "C,,". # Повторный вызов не дублирует сертификаты. # @SIDE_EFFECT Создаёт ~/.pki/nssdb/ если не существовал. # @LAYER Infrastructure install_ca_to_nss() { if ! command -v certutil >/dev/null 2>&1; then echo "[entrypoint] certutil (libnss3-tools) не найден — пропускаем NSS импорт" return 0 fi local nss_db_dir="${HOME:-/root}/.pki/nssdb" local nss_db="sql:${nss_db_dir}" # CA certs могут быть в llm/ или custom/ local target_dirs=( "/usr/local/share/ca-certificates/llm" "/usr/local/share/ca-certificates/custom" ) # Собираем все PEM-файлы local certs=() for dir in "${target_dirs[@]}"; do if [ -d "$dir" ]; then shopt -s nullglob for f in "$dir"/*.pem "$dir"/*.crt; do certs+=("$f") done shopt -u nullglob fi done if [ ${#certs[@]} -eq 0 ]; then echo "[entrypoint] Нет PEM-файлов для NSS импорта — пропускаем" return 0 fi # Создаём NSS DB если нет или если не открывается if [ ! -d "$nss_db_dir" ]; then mkdir -p "$nss_db_dir" echo "[entrypoint] Создание NSS DB: ${nss_db}" certutil -N -d "$nss_db" --empty-password 2>/dev/null || true elif ! certutil -L -d "$nss_db" >/dev/null 2>&1; then # DB существует но повреждена / неверный формат echo "[entrypoint] NSS DB повреждена, пересоздаём..." rm -f "${nss_db_dir}"/*.db "${nss_db_dir}"/pkcs11.txt 2>/dev/null || true certutil -N -d "$nss_db" --empty-password 2>/dev/null || true fi # Проверяем что DB работает if ! certutil -L -d "$nss_db" >/dev/null 2>&1; then echo "[entrypoint] ⚠ NSS DB не создалась — пропускаем" return 0 fi local imported=0 for cert in "${certs[@]}"; do local fingerprint fingerprint="$(openssl x509 -in "$cert" -noout -fingerprint -sha256 2>/dev/null | sed 's/.*=//' || true)" [ -z "$fingerprint" ] && continue # Используем директорию как префикс для nickname local dir_prefix dir_prefix="$(basename "$(dirname "$cert")")" local nickname="${dir_prefix}-$(basename "$cert" | sed 's/\.[^.]*$//')" # Дедупликация по fingerprint (не по nickname) if certutil -L -d "$nss_db" 2>/dev/null | grep -qF "$fingerprint"; then echo "[entrypoint] ✓ NSS: ${nickname} уже импортирован (fingerprint совпадает)" continue fi # Если nickname занят другим сертификатом — добавляем с уникальным именем local actual_nickname="$nickname" if certutil -L -d "$nss_db" -n "$actual_nickname" >/dev/null 2>&1; then actual_nickname="${nickname}-$(echo "$fingerprint" | cut -c1-8)" fi # Добавляем с trust-атрибутами "C,," (CA для TLS) if certutil -A -d "$nss_db" -n "$actual_nickname" -t "C,," -i "$cert" -a 2>/dev/null; then echo "[entrypoint] ➕ NSS импортирован: ${actual_nickname}" imported=$((imported + 1)) else echo "[entrypoint] ⚠ NSS ошибка импорта: ${nickname}" fi done if [ "$imported" -gt 0 ]; then echo "[entrypoint] ✅ NSS: импортировано ${imported} сертификатов" fi } # #endregion docker.backend.entrypoint.install_ca_to_nss # ====================================================================== # MAIN # ====================================================================== install_certificates install_llm_ca_certs install_ca_to_nss # Check DB state: empty, legacy (tables exist but no alembic), or managed # || prevents set -e from killing the script on non-zero exit from detection script _db_state=0 python3 -c " import os from sqlalchemy import create_engine, inspect e = create_engine(os.environ['DATABASE_URL']) insp = inspect(e) if 'alembic_version' in insp.get_table_names(): exit(0) if not insp.get_table_names(): exit(1) exit(2) " 2>&1 || _db_state=$? if [ $_db_state -eq 0 ]; then echo "[entrypoint] Running Alembic migrations (incremental)..." alembic upgrade head 2>&1 | sed 's/^/[entrypoint] /' echo "[entrypoint] ✅ Alembic migrations applied" elif [ $_db_state -eq 1 ]; then echo "[entrypoint] Empty database — running full Alembic upgrade..." alembic upgrade head 2>&1 | sed 's/^/[entrypoint] /' echo "[entrypoint] ✅ Alembic initial migration applied" else echo "[entrypoint] Legacy database — adding critical migration columns..." # Add boot-critical columns that Alembic migrations would add python3 -c " import os from sqlalchemy import create_engine, inspect, text e = create_engine(os.environ['DATABASE_URL']) with e.begin() as conn: inspector = inspect(conn) roles_cols = {c['name'] for c in inspector.get_columns('roles')} if 'is_admin' not in roles_cols: conn.execute(text('ALTER TABLE roles ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE')) conn.execute(text(\"UPDATE roles SET is_admin = TRUE WHERE name = 'Admin'\")) print(' roles.is_admin column added') else: print(' roles.is_admin already exists') " echo "[entrypoint] Stamping Alembic head (tables already exist)..." alembic stamp head 2>&1 | sed 's/^/[entrypoint] /' echo "[entrypoint] ✅ Alembic stamped head, schema ready" fi bootstrap_admin echo "[entrypoint] Starting backend: $*" exec "$@"