fix(docker): add missing certs.sh COPY to backend/all-in-one Dockerfile

Backend entrypoint sources certs.sh for CA certificate installation,
but the file was never copied into the Docker image — causing
container crash loop on startup (regression in 7f932610).

Changes:
- docker/backend.Dockerfile, docker/all-in-one.Dockerfile: COPY certs.sh
- docker/backend.entrypoint.sh: remove dead install_llm_ca_certs,
  install_certificates (replaced by docker/certs.sh); update @INVARIANT
- backend/src/core/ssl.py: fix describe_context() to report actual
  system cert count (SSLContext.capath attr does not exist in Python 3)
- __tests__: rewrite stale tests asserting LLM_SSL_VERIFY=false →
  verify=False; behaviour is permanently removed — always CERT_REQUIRED
- backend/tests/integration/test_backend_container.py [new]: 5 tests
  verifying certs.sh presence, sourceability, and full-stack smoke
  (testcontainers PG → entrypoint → migrations → health)
- conftest.py: restore health-wait loop and fixtures in superset_container
- ADR-0009, README.md, scripts, .env: align docs with centralized SSL
This commit is contained in:
2026-07-07 00:58:41 +03:00
parent aafd89f2c7
commit 702ee25ae4
12 changed files with 645 additions and 371 deletions

View File

@@ -51,7 +51,8 @@ COPY backend/ /app/backend/
# Frontend build (from stage 1)
COPY --from=frontend-builder /app/frontend/build /app/frontend/build
# Entrypoint
# Entrypoint + certs.sh
COPY docker/certs.sh /app/certs.sh
COPY docker/backend.entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh

View File

@@ -31,7 +31,8 @@ RUN python -m playwright install --with-deps chromium
# Исходный код
COPY backend/ /app/backend/
# Копируем entrypoint — установит корп. сертификаты, Playwright и сделает bootstrap admin
# Копируем shared certs.sh и entrypoint — установит корп. сертификаты, Playwright и сделает bootstrap admin
COPY docker/certs.sh /app/certs.sh
COPY docker/backend.entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh

View File

@@ -16,254 +16,18 @@ set -euo pipefail
# @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 Если CERTS_PATH пуст или нет сертификатов — certs.sh:install_all_certs не падает.
# @INVARIANT Существующий admin аккаунт НЕ перезаписывается при перезапуске контейнера
# (create_admin.py идемпотентен).
# @INVARIANT Повторный запуск install_llm_ca_certs не дублирует сертификаты в bundle.
# @INVARIANT Повторный запуск install_ca_to_nss не дублирует сертификаты в NSS DB.
# @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
# ======================================================================
# 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/\.[^.]*$//').crt"
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 cert_file in "$target_dir"/*.crt; do
[ -f "$cert_file" ] || continue
local cert_name
cert_name="$(basename "$cert_file" .crt)"
local cert_subject
cert_subject="$(openssl x509 -in "$cert_file" -noout -subject 2>/dev/null | head -1 || echo "unknown")"
local cert_fingerprint
cert_fingerprint="$(openssl x509 -in "$cert_file" -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 "$cert_file" >> /etc/ssl/certs/ca-certificates.crt
echo "[entrypoint] ➕ Добавлен напрямую в ca-certificates.crt"
fi
# Создаём хеш-симлинку с поддержкой коллизий (.0, .1, .2...)
local hash_val
hash_val="$(openssl x509 -in "$cert_file" -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")" = "$cert_file" ]; then
break 2 # уже есть, ничего не делаем
fi
suffix=$((suffix + 1))
link_path="/etc/ssl/certs/${hash_val}.${suffix}"
done
if [ ! -L "$link_path" ]; then
ln -sf "$cert_file" "$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
# 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 пользователя
@@ -323,12 +87,13 @@ bootstrap_admin() {
# для 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 (устанавливает в систему). Этот порядок
# @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 формируется как "llm-имя" или "custom-имя" для избежания коллизий.
# Nickname формируется как "custom-имя" (все сертификаты из CERTS_PATH).
# @REJECTED Использование Playwright --ignore-certificate-errors отвергнуто —
# отключает ВСЕ SSL проверки глобально, а не только для корп. CA.
# @REJECTED Nickname-only дедупликация отвергнута — при разных файлах
@@ -347,9 +112,8 @@ install_ca_to_nss() {
local nss_db_dir="${HOME:-/root}/.pki/nssdb"
local nss_db="sql:${nss_db_dir}"
# CA certs могут быть в llm/ или custom/
# All CA certs are installed via docker/certs.sh into custom/ dir
local target_dirs=(
"/usr/local/share/ca-certificates/llm"
"/usr/local/share/ca-certificates/custom"
)