Files
ss-tools/docker/backend.entrypoint.sh
busya 7ffddff2a6 feat: add LLM SSL verify control and auto CA cert download
- LLM_SSL_VERIFY env var to disable SSL verification for corporate proxies
- install_llm_ca_certs() entrypoint function — downloads PEM/DER certs
  from LLM_CA_CERT_URLS, converts DER→PEM, installs to system CA store
- _format_connection_error() — detailed exception chain logging
- 7 new unit tests for _get_ssl_verify, _format_connection_error, verify= param
- LLM_CA_CERT_URLS and LLM_SSL_VERIFY in .env.enterprise-clean
- Removed duplicate @RELATION DEPENDS_ON -> [EXT:Library:tenacity]
2026-05-27 14:44:46 +03:00

341 lines
15 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 — установка корп. сертификатов, Playwright (lazy), bootstrap admin,
# затем запуск backend (exec "$@").
# @PRE Python runtime и backend source доступны в /app/backend.
# Переменная CERTS_PATH указывает на директорию с .crt файлами (или пусто).
# @POST Сертификаты установлены в системное хранилище. Playwright Chromium установлен (lazy).
# Admin создан (если INITIAL_ADMIN_CREATE=true). Backend запущен через exec "$@".
# @SIDE_EFFECT Модифицирует /usr/local/share/ca-certificates/ и вызывает update-ca-certificates.
# Скачивает Chromium ~377MB при первом запуске.
# @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 Playwright Chromium устанавливается только если отсутствует в кеше.
# #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
for cert in "$certs_dir"/*.crt "$certs_dir"/*.pem; do
if [ -f "$cert" ]; then
cp -v "$cert" "$target/" 2>&1 | sed 's/^/[entrypoint] /'
copied=$((copied + 1))
fi
done
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-совместимости).
# @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
local downloaded_names=""
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))
if curl -sS --connect-timeout 10 --max-time 30 -o "$raw_file" "$url" 2>/dev/null; then
success=1
break
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"
openssl x509 -in "$raw_file" -inform DER -out "$pem_file" -outform PEM
else
echo "[entrypoint] ❌ Неизвестный формат (не PEM и не DER) — пропускаем"
rm -f "$raw_file"
continue
fi
chmod 644 "$pem_file"
echo "[entrypoint] ✅ Установлен: $(basename "$pem_file")"
downloaded_names="$downloaded_names $(basename "$pem_file" .pem)"
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] --- Валидация установки сертификатов ---"
local all_ok=true
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")"
# Проверяем есть ли в ca-certificates.crt
if grep -q "$cert_name" /etc/ssl/certs/ca-certificates.crt 2>/dev/null || \
grep -qF "$(openssl x509 -in "$pem" -noout -serial 2>/dev/null)" /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
# Создаём хеш-симлинку в /etc/ssl/certs/
local hash_val
hash_val="$(openssl x509 -in "$pem" -noout -hash 2>/dev/null || true)"
if [ -n "$hash_val" ]; then
local link_path="/etc/ssl/certs/${hash_val}.0"
if [ ! -L "$link_path" ]; then
ln -sf "$pem" "$link_path"
echo "[entrypoint] 🔗 Создана симлинка: ${hash_val}.0 → ${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_playwright — lazy установка Chromium
# ======================================================================
# #region docker.backend.entrypoint.install_playwright [C:2] [TYPE Function]
# @BRIEF Lazy установка Playwright Chromium (~377 MB) при первом запуске.
# @PRE ~/.cache/ms-playwright доступен (можно закешировать volume).
# @POST Chromium установлен в PLAYWRIGHT_BROWSERS_PATH.
# @LAYER Infrastructure
install_playwright() {
local pw_dir="${PLAYWRIGHT_BROWSERS_PATH:-$HOME/.cache/ms-playwright}"
if ls "${pw_dir}"/chromium-*/chrome-linux64/chrome 1>/dev/null 2>&1; then
echo "[entrypoint] Playwright Chromium already installed at ${pw_dir}"
return 0
fi
echo "[entrypoint] Installing Playwright Chromium (first start only, ~377 MB)..."
echo "[entrypoint] Cache with: -v playwright_cache:/root/.cache/ms-playwright"
python3 -m playwright install chromium --with-deps 2>&1
echo "[entrypoint] ✅ Playwright Chromium installed"
}
# #endregion docker.backend.entrypoint.install_playwright
# ======================================================================
# MAIN
# ======================================================================
install_certificates
install_llm_ca_certs
install_playwright
# 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 "$@"