Files
ss-tools/docker/frontend.entrypoint.sh
busya 1c6b3d6e8a feat(ssl): add passphrase-protected key and PKCS#12 support in nginx entrypoint
- docker/frontend.entrypoint.sh: add 3 new functions:
  - extract_p12_if_needed — openssl pkcs12 extraction to .crt+.key
  - resolve_ssl_passphrase — read SSL_KEY_PASSPHRASE env var
  - decrypt_key_if_needed — openssl rsa decryption before nginx start
- Pipeline: install CA -> extract p12 -> get passphrase -> select config -> decrypt key -> start nginx
- Crash-early: encrypted key without SSL_KEY_PASSPHRASE exits entrypoint
- docker-compose.enterprise-clean.yml: add SSL_KEY_PASSPHRASE to frontend env
- .env.enterprise-clean.example: document SSL_KEY_PASSPHRASE usage
- build.sh: add SSL_KEY_PASSPHRASE to generated deploy compose
2026-07-04 17:05:51 +03:00

257 lines
14 KiB
Bash
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.

#!/bin/sh
set -euo pipefail
# #region docker.frontend.entrypoint [C:4] [TYPE Module] [SEMANTICS docker,nginx,entrypoint,certs,ssl,passphrase]
# @BRIEF Entrypoint для nginx-контейнера — установка корп. CA-сертификатов в Alpine,
# авто-выбор HTTP или SSL конфига, поддержка passphrase-защищённых ключей и PKCS#12.
# @PRE /opt/certs смонтирован (может быть пуст). Системные пакеты: ca-certificates, openssl.
# SSL_KEY_PASSPHRASE — опциональный пароль от приватного ключа.
# @POST Если найдены server.crt + server.key — активирован SSL конфиг.
# Если есть server.p12 — извлечены .crt и .key.
# Если server.key зашифрован + есть SSL_KEY_PASSPHRASE — ключ расшифрован.
# CA-сертификаты из /opt/certs установлены в системное хранилище Alpine.
# @SIDE_EFFECT Модифицирует /usr/local/share/ca-certificates/; перезаписывает nginx конфиг.
# Расшифровывает приватный ключ в /etc/nginx/ssl/ при наличии passphrase.
# @LAYER Infrastructure
# @RELATION DEPENDS_ON -> [docker/nginx.conf]
# @RELATION DEPENDS_ON -> [docker/nginx.ssl.conf]
# @INVARIANT Порт 80 всегда активен (HTTP или редирект на HTTPS).
# @INVARIANT Если нет server.crt — используется HTTP-only конфиг.
# @INVARIANT Зашифрованный ключ без SSL_KEY_PASSPHRASE — nginx не стартует (crash-early).
# @DATA_CONTRACT Input: /opt/certs/{server.crt, server.key, server.p12?} + SSL_KEY_PASSPHRASE? ->
# Output: /etc/nginx/ssl/{server.crt, server.key} (дешифрованный)
# @RATIONALE Поддержка парольных ключей и PKCS#12 нужна для корпоративных PKI,
# которые часто выдают контейнеры .p12 с passphrase вместо отдельных файлов.
# @REJECTED Хранение пароля в файле на диске (/opt/certs/passphrase.txt) отвергнуто —
# переменная окружения безопаснее (не персистится в volume) и следует тому же
# паттерну, что и остальные секреты в проекте.
# #endregion docker.frontend.entrypoint
# ======================================================================
# install_ca_certificates — корпоративные CA в Alpine
# ======================================================================
# #region docker.frontend.entrypoint.install_ca_certificates [C:2] [TYPE Function]
# @BRIEF Устанавливает .crt файлы из /opt/certs в системное хранилище Alpine.
# @PRE /opt/certs может быть пуст или не существовать.
# @POST Сертификаты скопированы в /usr/local/share/ca-certificates/ и добавлены в trust.
install_ca_certificates() {
local certs_dir="${CERTS_PATH:-/opt/certs}"
if [ ! -d "$certs_dir" ]; then
echo "[entrypoint] CERTS_PATH=${certs_dir} не существует — пропускаем CA-сертификаты"
return 0
fi
local count=0
for cert in "$certs_dir"/*.crt "$certs_dir"/*.pem; do
[ -f "$cert" ] || continue
# Не копируем server.crt — он для nginx, не для CA
case "$(basename "$cert")" in
server.crt|server.key) continue ;;
esac
cp -v "$cert" /usr/local/share/ca-certificates/ 2>&1 | sed 's/^/[entrypoint] /'
count=$((count + 1))
done
if [ "$count" -gt 0 ]; then
echo "[entrypoint] Обновляем CA-сертификаты Alpine..."
update-ca-certificates 2>&1 | sed 's/^/[entrypoint] /'
echo "[entrypoint] ✅ Установлено ${count} корпоративных CA-сертификатов"
else
echo "[entrypoint] Нет CA-сертификатов для установки"
fi
}
# #endregion docker.frontend.entrypoint.install_ca_certificates
# ======================================================================
# extract_p12_if_needed — конвертация PKCS#12 в CRT+KEY
# ======================================================================
# #region docker.frontend.entrypoint.extract_p12 [C:3] [TYPE Function] [SEMANTICS pkcs12,p12,certificate,extraction]
# @BRIEF Если в /opt/certs есть server.p12, извлекает server.crt + server.key через openssl pkcs12.
# @PRE /opt/certs/server.p12 может существовать. SSL_KEY_PASSPHRASE — опциональный пароль.
# @POST Если server.p12 найден — server.crt и server.key извлечены в /opt/certs/.
# Существующие server.crt/server.key НЕ перезаписываются (.p12 обрабатывается только
# если нет .crt или .key).
# @SIDE_EFFECT Записывает server.crt и server.key в CERTS_PATH.
# @LAYER Infrastructure
# @RELATION DEPENDS_ON -> [openssl (system package)]
# @EXAMPLE:
# ./certs/server.p12 (PKCS#12 контейнер)
# SSL_KEY_PASSPHRASE=my-passphrase
# → ./certs/server.crt + ./certs/server.key извлечены
extract_p12_if_needed() {
local certs_dir="${CERTS_PATH:-/opt/certs}"
local p12_file="${certs_dir}/server.p12"
[ -f "$p12_file" ] || return 0
# Не перезаписываем, если .crt и .key уже есть (приоритет ручной установки)
local ssl_cert="${certs_dir}/server.crt"
local ssl_key="${certs_dir}/server.key"
if [ -f "$ssl_cert" ] && [ -f "$ssl_key" ]; then
echo "[entrypoint] server.crt + server.key уже существуют — пропускаем извлечение из .p12"
return 0
fi
echo "[entrypoint] 📦 Найден server.p12 — извлекаем сертификат и ключ..."
local pass_opt=""
if [ -n "${SSL_KEY_PASSPHRASE:-}" ]; then
pass_opt="-passin env:SSL_KEY_PASSPHRASE"
fi
# Извлечь сертификат (без ключа)
# shellcheck disable=SC2086
openssl pkcs12 -in "$p12_file" \
$pass_opt \
-clcerts -nokeys -out "$ssl_cert" 2>&1 | sed 's/^/[entrypoint] /'
# Извлечь приватный ключ (без шифрования на выходе)
# shellcheck disable=SC2086
openssl pkcs12 -in "$p12_file" \
$pass_opt \
-nocerts -nodes -out "$ssl_key" 2>&1 | sed 's/^/[entrypoint] /'
# Проверить, что извлеклось
if [ -f "$ssl_cert" ] && [ -f "$ssl_key" ]; then
echo "[entrypoint] ✅ Сертификат и ключ извлечены из server.p12"
else
echo "[entrypoint] ⚠️ Ошибка извлечения из server.p12 — проверьте SSL_KEY_PASSPHRASE"
return 1
fi
}
# #endregion docker.frontend.entrypoint.extract_p12
# ======================================================================
# resolve_ssl_passphrase — получение пароля для ключа
# ======================================================================
# #region docker.frontend.entrypoint.resolve_passphrase [C:1] [TYPE Function] [SEMANTICS passphrase,ssl]
# @BRIEF Возвращает пароль для SSL-ключа из SSL_KEY_PASSPHRASE (или пустую строку).
# Резервирует место для будущего расширения (Fernet-дешифровка SSL_KEY_PASSPHRASE_ENCRYPTED).
# @POST Переменная ssl_passphrase установлена (может быть пуста).
# @LAYER Infrastructure
# @RELATION DEPENDS_ON -> [SSL_KEY_PASSPHRASE (env)]
resolve_ssl_passphrase() {
local passphrase="${SSL_KEY_PASSPHRASE:-}"
# Будущее расширение: Fernet-дешифровка SSL_KEY_PASSPHRASE_ENCRYPTED
# через ENCRYPTION_KEY, если SSL_KEY_PASSPHRASE не задана.
echo "$passphrase"
}
# #endregion docker.frontend.entrypoint.resolve_passphrase
# ======================================================================
# decrypt_key_if_needed — расшифровка passphrase-защищённого ключа
# ======================================================================
# #region docker.frontend.entrypoint.decrypt_key [C:3] [TYPE Function] [SEMANTICS decryption,rsa,ssl,passphrase]
# @BRIEF Проверяет, зашифрован ли server.key, и если да — расшифровывает его.
# @PRE ssl_dir — директория, куда скопирован server.key. ssl_passphrase — пароль (может быть пуст).
# @POST Если ключ зашифрован и пароль верен — ключ расшифрован в ssl_dir.
# Если зашифрован, но пароля нет — crash с ошибкой (nginx не сможет стартовать).
# @SIDE_EFFECT Перезаписывает ssl_dir/server.key расшифрованной версией.
# @LAYER Infrastructure
# @RELATION DEPENDS_ON -> [openssl (system package)]
# @RELATION CALLED_BY -> [docker.frontend.entrypoint.select_nginx_config]
# @INVARIANT Если ключ зашифрован и SSL_KEY_PASSPHRASE пуст — entrypoint exit(1).
# Это crash-early поведение: лучше явная ошибка при старте, чем nginx, который
# молча не принимает HTTPS-соединения.
# @RATIONALE Расшифровка происходит ПОСЛЕ копирования ключа в /etc/nginx/ssl/,
# а не mount-директорию, потому что /opt/certs смонтирован read-only.
# Расшифрованный ключ остаётся в tmpfs (/etc/nginx/ssl/) и не пишется на диск хоста.
decrypt_key_if_needed() {
local ssl_dir="/etc/nginx/ssl"
local ssl_key="${ssl_dir}/server.key"
local ssl_passphrase="$1"
[ -f "$ssl_key" ] || return 0
# Проверка: ключ зашифрован?
if openssl rsa -in "$ssl_key" -check -noout 2>/dev/null; then
# Ключ незашифрован — ничего не делаем
return 0
fi
# Ключ зашифрован — нужен пароль
if [ -z "$ssl_passphrase" ]; then
echo "[entrypoint] ❌ Приватный ключ зашифрован, но SSL_KEY_PASSPHRASE не задана."
echo "[entrypoint] Установите переменную окружения SSL_KEY_PASSPHRASE или используйте"
echo "[entrypoint] незашифрованный ключ."
return 1
fi
echo "[entrypoint] 🔐 Приватный ключ зашифрован — расшифровываем..."
# Расшифровать через openssl rsa
if openssl rsa -in "$ssl_key" \
-passin "env:SSL_KEY_PASSPHRASE" \
-out "${ssl_key}.decrypted" 2>/dev/null; then
mv "${ssl_key}.decrypted" "$ssl_key"
chmod 600 "$ssl_key"
echo "[entrypoint] ✅ Приватный ключ расшифрован"
else
echo "[entrypoint] ❌ Ошибка расшифровки ключа — неверный SSL_KEY_PASSPHRASE?"
rm -f "${ssl_key}.decrypted"
return 1
fi
}
# #endregion docker.frontend.entrypoint.decrypt_key
# ======================================================================
# select_nginx_config — выбор HTTP vs SSL
# ======================================================================
# #region docker.frontend.entrypoint.select_nginx_config [C:3] [TYPE Function] [SEMANTICS nginx,tls,ssl,config]
# @BRIEF Определяет, есть ли сертификаты для SSL, и копирует нужный nginx конфиг.
# Ключ копируется как есть (если был зашифрован — decrypt_key_if_needed расшифрует после).
# @PRE /opt/certs/server.crt и /opt/certs/server.key — опциональные SSL-сертификаты.
# Если сертификаты были извлечены из .p12 — они уже лежат в certs_dir.
# @POST Если есть оба файла — /etc/nginx/ssl/ создан, server.crt/key скопированы,
# используется nginx.ssl.conf. Иначе — nginx.conf (HTTP-only).
# @INVARIANT Если ключ зашифрован, он будет расшифрован decrypt_key_if_needed()
# ПОСЛЕ этой функции. select_nginx_config не расшифровывает ключи самостоятельно.
select_nginx_config() {
local certs_dir="${CERTS_PATH:-/opt/certs}"
local ssl_cert="${certs_dir}/server.crt"
local ssl_key="${certs_dir}/server.key"
local ssl_dir="/etc/nginx/ssl"
if [ -f "$ssl_cert" ] && [ -f "$ssl_key" ]; then
echo "[entrypoint] Найдены SSL-сертификаты — включаем HTTPS"
mkdir -p "$ssl_dir"
cp "$ssl_cert" "${ssl_dir}/server.crt"
cp "$ssl_key" "${ssl_dir}/server.key"
# Копируем SSL-конфиг nginx
cp /docker/nginx.ssl.conf /etc/nginx/conf.d/default.conf
echo "[entrypoint] ✅ SSL терминация активирована на порту 443"
else
echo "[entrypoint] SSL-сертификаты не найдены — используем HTTP-only режим"
echo "[entrypoint] Чтобы включить HTTPS, положите в ${certs_dir}:"
echo "[entrypoint] server.crt — SSL сертификат"
echo "[entrypoint] server.key — приватный ключ (может быть зашифрован)"
echo "[entrypoint] Или PKCS#12 с passphrase:"
echo "[entrypoint] server.p12 — контейнер PKCS#12"
echo "[entrypoint] SSL_KEY_PASSPHRASE — пароль от ключа/.p12"
cp /docker/nginx.conf /etc/nginx/conf.d/default.conf
fi
}
# #endregion docker.frontend.entrypoint.select_nginx_config
# ── Pipeline ──────────────────────────────────────────────────────────
install_ca_certificates
extract_p12_if_needed
ssl_passphrase="$(resolve_ssl_passphrase)"
select_nginx_config
decrypt_key_if_needed "$ssl_passphrase"
# ── Start nginx ──────────────────────────────────────────────────────
echo "[entrypoint] Starting nginx..."
exec "$@"