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
252 lines
8.8 KiB
Bash
252 lines
8.8 KiB
Bash
#!/usr/bin/env bash
|
||
# #region docker.certs [C:3] [TYPE Module] [SEMANTICS docker,certs,ssl,ca,trust]
|
||
# @BRIEF Shared certificate installation functions for all containers.
|
||
# @LAYER Infrastructure
|
||
# @PRE CERTS_PATH points to mounted /opt/certs directory (may be empty or absent).
|
||
# @POST Corporate CA certificates installed into system trust store and NSS DB.
|
||
# @INVARIANT server.crt, server.key, server.p12, *.key, *.p12, *.pfx are never imported as CA.
|
||
|
||
set -euo pipefail
|
||
|
||
# ── normalize_cert – PEM/DER detection + conversion ──────────────────
|
||
|
||
normalize_cert() {
|
||
local input="$1"
|
||
local output="$2"
|
||
if openssl x509 -in "$input" -inform PEM -noout 2>/dev/null; then
|
||
cp "$input" "$output"
|
||
return 0
|
||
fi
|
||
if openssl x509 -in "$input" -inform DER -noout 2>/dev/null; then
|
||
openssl x509 -in "$input" -inform DER -out "$output" -outform PEM 2>/dev/null
|
||
return 0
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
# ── download_llm_ca_certs – download certs from HTTP URLs ────────────
|
||
# @RATIONALE HTTP download from corporate PKI (e.g. pki.rusal.com) is the
|
||
# primary delivery mechanism for CA certificates. No chicken-and-egg TLS
|
||
# problem because URLs use plain HTTP, not HTTPS.
|
||
# @REJECTED HTTP-only HTTPS redirect was rejected — PKI servers may redirect
|
||
# HTTP→HTTPS using the same CA chain we are downloading (true chicken-and-egg).
|
||
download_llm_ca_certs() {
|
||
local urls="${LLM_CA_CERT_URLS:-}"
|
||
if [ -z "$urls" ]; then
|
||
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 "[certs] === Скачивание CA-сертификатов из LLM_CA_CERT_URLS ==="
|
||
local index=0 downloaded=0
|
||
|
||
for url in $urls; do
|
||
url="$(echo "$url" | xargs)" # trim
|
||
[ -z "$url" ] && continue
|
||
index=$((index + 1))
|
||
|
||
case "$url" in
|
||
http://*) ;;
|
||
*)
|
||
echo "[certs] ⚠️ пропускаем не-HTTP URL: ${url}"
|
||
continue
|
||
;;
|
||
esac
|
||
|
||
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 "[certs] [${index}] Скачивание: ${url}"
|
||
|
||
local attempt=0 success=0
|
||
while [ $attempt -lt 3 ]; do
|
||
attempt=$((attempt + 1))
|
||
if curl -fsSL --proto '=http' --proto-redir '=http' --connect-timeout 10 --max-time 30 -o "$raw_file" "$url" 2>/dev/null; then
|
||
success=1
|
||
break
|
||
fi
|
||
echo "[certs] ⚠ попытка ${attempt}/3 не удалась, повтор через 2с..."
|
||
sleep 2
|
||
done
|
||
|
||
if [ "$success" -eq 0 ]; then
|
||
echo "[certs] ❌ Не удалось скачать: ${url} — пропускаем"
|
||
continue
|
||
fi
|
||
|
||
# Detect format: PEM or DER
|
||
if openssl x509 -in "$raw_file" -inform PEM -noout 2>/dev/null; then
|
||
echo "[certs] ✓ Формат: PEM"
|
||
cp "$raw_file" "$pem_file"
|
||
elif openssl x509 -in "$raw_file" -inform DER -noout 2>/dev/null; then
|
||
echo "[certs] ↻ Формат: DER — конвертируем в PEM"
|
||
if ! openssl x509 -in "$raw_file" -inform DER -out "$pem_file" -outform PEM 2>&1; then
|
||
echo "[certs] ❌ Ошибка конвертации DER→PEM — пропускаем"
|
||
rm -f "$raw_file" "${pem_file}"
|
||
continue
|
||
fi
|
||
else
|
||
echo "[certs] ❌ Неизвестный формат (не PEM и не DER) — пропускаем"
|
||
rm -f "$raw_file"
|
||
continue
|
||
fi
|
||
|
||
chmod 644 "$pem_file"
|
||
echo "[certs] ✅ Установлен: $(basename "$pem_file")"
|
||
downloaded=$((downloaded + 1))
|
||
rm -f "$raw_file"
|
||
done
|
||
|
||
if [ "$downloaded" -gt 0 ]; then
|
||
echo "[certs] Скачано и установлено: ${downloaded}"
|
||
fi
|
||
rm -rf "$work_dir"
|
||
}
|
||
|
||
# ── install_all_certs – single unified entry point ───────────────────
|
||
|
||
install_all_certs() {
|
||
local certs_dir="${CERTS_PATH:-/opt/certs}"
|
||
if [ ! -d "$certs_dir" ]; then
|
||
echo "[certs] CERTS_PATH=${certs_dir} не существует – пропускаем"
|
||
fi
|
||
|
||
local target="/usr/local/share/ca-certificates/custom"
|
||
mkdir -p "$target"
|
||
|
||
if [ -d "$certs_dir" ]; then
|
||
echo "[certs] Устанавливаем корпоративные сертификаты из ${certs_dir}..."
|
||
local installed=0 skipped=0 invalid=0
|
||
|
||
for f in "$certs_dir"/*; do
|
||
[ -f "$f" ] || continue
|
||
local name
|
||
name="$(basename "$f")"
|
||
|
||
# Skip non-CA server/private files
|
||
case "$(echo "$name" | tr '[:upper:]' '[:lower:]')" in
|
||
server.crt|server.key|server.pem|*.key|*.p12|*.pfx)
|
||
echo "[certs] ⏭️ пропускаем: ${name} (server/private файл)"
|
||
skipped=$((skipped + 1))
|
||
continue
|
||
;;
|
||
esac
|
||
|
||
# Accept .crt, .pem, .cer, .der
|
||
case "$(echo "$name" | tr '[:upper:]' '[:lower:]')" in
|
||
*.crt|*.pem|*.cer|*.der)
|
||
;;
|
||
*)
|
||
echo "[certs] ⚠️ неизвестный формат: ${name} – пропускаем"
|
||
skipped=$((skipped + 1))
|
||
continue
|
||
;;
|
||
esac
|
||
|
||
local out_name="${name%.*}.crt"
|
||
local out_file="${target}/${out_name}"
|
||
|
||
if normalize_cert "$f" "$out_file"; then
|
||
echo "[certs] ✅ ${name} (→ ${out_name})"
|
||
installed=$((installed + 1))
|
||
else
|
||
echo "[certs] ❌ невалидный сертификат: ${name}"
|
||
invalid=$((invalid + 1))
|
||
fi
|
||
done
|
||
|
||
echo "[certs] Установлено: ${installed}, пропущено: ${skipped}, невалидно: ${invalid}"
|
||
fi
|
||
# ]]] SED_END_MARKER — do not remove, used by sed in update script
|
||
|
||
# Update system CA store (picks up both custom/ and llm/ dirs)
|
||
if command -v update-ca-certificates &>/dev/null; then
|
||
echo "[certs] Обновляем системное хранилище CA-сертификатов..."
|
||
update-ca-certificates --fresh 2>&1 | sed 's/^/[certs] /'
|
||
fi
|
||
|
||
# Create hash symlinks for OpenSSL capath (scan both custom/ and llm/)
|
||
echo "[certs] Создаём хеш-симлинки..."
|
||
local sym_count=0
|
||
for ca_dir in /usr/local/share/ca-certificates/custom /usr/local/share/ca-certificates/llm; do
|
||
[ -d "$ca_dir" ] || continue
|
||
for cert_file in "$ca_dir"/*.crt; do
|
||
[ -f "$cert_file" ] || continue
|
||
local cert_name
|
||
cert_name="$(basename "$cert_file" .crt)"
|
||
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}"
|
||
local already_linked=0
|
||
while [ -L "$link_path" ]; do
|
||
if [ "$(readlink "$link_path")" = "$cert_file" ]; then
|
||
already_linked=1
|
||
break
|
||
fi
|
||
suffix=$((suffix + 1))
|
||
link_path="/etc/ssl/certs/${hash_val}.${suffix}"
|
||
done
|
||
if [ "$already_linked" -eq 0 ] && [ ! -L "$link_path" ]; then
|
||
ln -sf "$cert_file" "$link_path"
|
||
sym_count=$((sym_count + 1))
|
||
fi
|
||
fi
|
||
done
|
||
done
|
||
echo "[certs] Создано хеш-симлинок: ${sym_count}"
|
||
}
|
||
|
||
# ── install_ca_to_nss – NSS DB for Chromium/Playwright ──────────────
|
||
|
||
install_ca_to_nss() {
|
||
if ! command -v certutil &>/dev/null; then
|
||
return 0
|
||
fi
|
||
local nssdb="${HOME}/.pki/nssdb"
|
||
mkdir -p "$nssdb"
|
||
if [ ! -f "${nssdb}/cert9.db" ]; then
|
||
certutil -N -d "sql:${nssdb}" --empty-password >/dev/null 2>&1 || true
|
||
fi
|
||
local nss_count=0
|
||
echo "[certs] Импортируем сертификаты в NSS DB..."
|
||
for ca_dir in /usr/local/share/ca-certificates/custom /usr/local/share/ca-certificates/llm; do
|
||
[ -d "$ca_dir" ] || continue
|
||
local prefix
|
||
prefix="$(basename "$ca_dir")"
|
||
for cert_file in "$ca_dir"/*.crt; do
|
||
[ -f "$cert_file" ] || continue
|
||
local cert_name
|
||
cert_name="$(basename "$cert_file" .crt)"
|
||
certutil -A -d "sql:${nssdb}" -t "C,," -n "${prefix}-${cert_name}" -i "$cert_file" 2>/dev/null && nss_count=$((nss_count + 1)) || true
|
||
done
|
||
done
|
||
echo "[certs] Импортировано в NSS: ${nss_count}"
|
||
}
|
||
|
||
# ── skip_server_files – remove server certs from CA dir ──────────────
|
||
|
||
skip_server_files_filter() {
|
||
# Used by frontend to skip server.crt from being installed as CA
|
||
local certs_dir="${1:-/opt/certs}"
|
||
[ -d "$certs_dir" ] || return 0
|
||
for f in "$certs_dir"/*.crt "$certs_dir"/*.pem; do
|
||
[ -f "$f" ] || continue
|
||
case "$(basename "$f" | tr '[:upper:]' '[:lower:]')" in
|
||
server.crt|server.key) continue ;;
|
||
esac
|
||
echo "$f"
|
||
done
|
||
}
|
||
# #endregion docker.certs
|