Files
ss-tools/docker/backend.entrypoint.sh
busya 18b9f2e94e fix(certs): restore HTTP CA auto-download and test edge matrix
Restore LLM_CA_CERT_URLS support for corporate PKI HTTP cert delivery.
Downloaded certificates are installed into the llm/ CA category and share
the same system trust + NSS import path as CERTS_PATH-mounted certs.

Changes:
- certs.sh: add download_llm_ca_certs with HTTP-only curl policy,
  PEM/DER handling, retries, llm/ storage, hash symlinks for llm+custom
- backend/agent entrypoints: run download before install_all_certs
- compose/env/docs: expose LLM_CA_CERT_URLS again and update ADR-0009
- integration tests: cover PEM/DER/CER, invalid certs, non-HTTP skips,
  query-string filenames, private/server skips, empty inputs, combined
  LLM_CA_CERT_URLS + CERTS_PATH channels, and NSS imports

Verified:
- 194 passed, 1 skipped integration tests
- full backend container smoke passed with testcontainers PostgreSQL
- docker bundle rebuilt for 0.5.0
2026-07-07 10:23:49 +03:00

307 lines
14 KiB
Bash
Executable File
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 bash
set -euo pipefail
# #region docker.backend.entrypoint [C:4] [TYPE Module] [SEMANTICS docker,entrypoint,enterprise,certs]
# @BRIEF Container entrypoint — установка корп. сертификатов, wait-for-DB,
# Alembic миграции, bootstrap admin, затем запуск backend (exec "$@").
# @PRE Python runtime и backend source доступны в /app/backend.
# Переменная CERTS_PATH указывает на директорию с .crt файлами (или пусто).
# DATABASE_URL установлен и указывает на доступный PostgreSQL.
# @POST Сертификаты установлены в системное хранилище и NSS базу Chromium.
# БД подтверждена как доступная. Alembic миграции применены.
# Admin создан (если INITIAL_ADMIN_CREATE=true). Backend запущен через exec "$@".
# @SIDE_EFFECT Модифицирует /usr/local/share/ca-certificates/ и вызывает update-ca-certificates.
# Импортирует CA в NSS DB (~/.pki/nssdb) для Chromium.
# Выполняет Alembic upgrade head.
# @LAYER Infrastructure
# @RELATION DEPENDS_ON -> [backend/src/scripts/create_admin.py]
# @RELATION DEPENDS_ON -> [backend/src/scripts/init_auth_db.py]
# @INVARIANT Если CERTS_PATH пуст или нет сертификатов — certs.sh:install_all_certs не падает.
# @INVARIANT Существующий admin аккаунт НЕ перезаписывается при перезапуске контейнера
# (create_admin.py идемпотентен).
# @INVARIANT Повторный запуск certs.sh:install_all_certs не дублирует сертификаты в bundle.
# @INVARIANT Повторный запуск certs.sh:install_ca_to_nss не дублирует сертификаты в NSS DB.
# @INVARIANT wait_for_db exit(1) если БД недоступна после DB_WAIT_ATTEMPTS попыток —
# контейнер не продолжит запуск с недоступной БД.
# @INVARIANT Alembic миграции выполняются ТОЛЬКО в entrypoint, НЕ в app.py lifespan.
# #endregion docker.backend.entrypoint
# NOTE: install_certificates and install_llm_ca_certs removed — replaced by docker/certs.sh:install_all_certs.
# Centralized CERTS_PATH is the only certificate source. LLM_CA_CERT_URLS env var is removed (refs ADR-0009).
# ======================================================================
# 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 импорт выполняется после certs.sh:install_all_certs
# (устанавливает .crt в /usr/local/share/ca-certificates/custom/)
# и update-ca-certificates (добавляет в систему). Этот порядок
# гарантирует, что PEM-файлы уже есть на диске до создания NSS DB.
# @RATIONALE Для дедупликации используется SHA256 fingerprint сертификата,
# а не nickname — nickname может совпадать для файлов из разных директорий.
# Nickname формируется как "custom-имя" (все сертификаты из CERTS_PATH).
# @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}"
# All CA certs are installed via docker/certs.sh into custom/ dir
local target_dirs=(
"/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
# ======================================================================
# wait_for_db — проверка доступности БД с retry
# ======================================================================
# #region docker.backend.entrypoint.wait_for_db [C:3] [TYPE Function]
# @BRIEF Проверяет доступность PostgreSQL с retry-логикой перед запуском миграций.
# @PRE DATABASE_URL установлен и указывает на валидный PostgreSQL.
# @POST Соединение с БД подтверждено (SELECT 1) или скрипт exit(1) после таймаута.
# @RATIONALE Без wait-for-db enterprise-деплой с внешним PostgreSQL падает
# мгновенно — entrypoint интерпретирует connection refused как "пустая БД".
# @REJECTED Использование pg_isready напрямую отвергнуто — клиент psql может
# отсутствовать в python:3.11-slim образе. SQLAlchemy-проверка универсальна.
wait_for_db() {
local max_attempts="${DB_WAIT_ATTEMPTS:-30}"
local delay="${DB_WAIT_DELAY:-2}"
local attempt=0
echo "[entrypoint] Waiting for database (max ${max_attempts} attempts, ${delay}s delay)..."
while [ $attempt -lt $max_attempts ]; do
attempt=$((attempt + 1))
# Capture stderr to detect permanent errors (KeyError, ArgumentError)
local _err_output
_err_output="$(python3 -c "
import os, sys
from sqlalchemy import create_engine, text
try:
url = os.environ['DATABASE_URL']
except KeyError:
print('FATAL: DATABASE_URL environment variable is not set', file=sys.stderr)
sys.exit(99)
try:
e = create_engine(url)
with e.connect() as c:
c.execute(text('SELECT 1'))
except Exception as exc:
# Permanent errors — no point retrying
err_str = str(exc).lower()
if 'database_url' in err_str or 'could not parse' in err_str or 'invalid' in err_str:
print(f'FATAL: {exc}', file=sys.stderr)
sys.exit(99)
sys.exit(1)
" 2>&1)"
local _rc=$?
if [ $_rc -eq 0 ]; then
echo "[entrypoint] ✅ Database is reachable"
return 0
fi
# Permanent error — exit immediately, don't waste retries
if [ $_rc -eq 99 ]; then
echo "[entrypoint] ❌ $_err_output" >&2
return 1
fi
echo "[entrypoint] Attempt ${attempt}/${max_attempts} — retrying in ${delay}s..."
sleep "$delay"
done
# P0: Mask credentials — never print full DATABASE_URL to logs
local _masked_url
_masked_url="$(echo "$DATABASE_URL" | sed -E 's|://([^:]+):([^@]+)@|://\1:***@|')"
echo "[entrypoint] ❌ Database not reachable after ${max_attempts} attempts" >&2
echo "[entrypoint] DATABASE_URL=${_masked_url}" >&2
return 1
}
# #endregion docker.backend.entrypoint.wait_for_db
# ======================================================================
# MAIN
# ======================================================================
source "$(dirname "$0")/certs.sh"
download_llm_ca_certs
install_all_certs
install_ca_to_nss
# ── C1: Wait for database before any migration logic ──
wait_for_db
# ── C2: DB state detection (now safe — DB is confirmed reachable) ──
_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 — running Alembic upgrade (not stamp)..."
# H2: Запускаем полноценный upgrade вместо stamp head — legacy-БД может
# отсутствовать таблицы, добавленные миграциями после написания entrypoint.
alembic upgrade head 2>&1 | sed 's/^/[entrypoint] /'
echo "[entrypoint] ✅ Alembic upgrade complete for legacy database"
fi
bootstrap_admin
echo "[entrypoint] Starting backend: $*"
exec "$@"