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
This commit is contained in:
2026-07-04 17:05:51 +03:00
parent 047aff41d9
commit 1c6b3d6e8a
4 changed files with 194 additions and 5 deletions

View File

@@ -1,18 +1,30 @@
#!/bin/sh
set -euo pipefail
# #region docker.frontend.entrypoint [C:3] [TYPE Module] [SEMANTICS docker,nginx,entrypoint,certs,ssl]
# #region docker.frontend.entrypoint [C:4] [TYPE Module] [SEMANTICS docker,nginx,entrypoint,certs,ssl,passphrase]
# @BRIEF Entrypoint для nginx-контейнера — установка корп. CA-сертификатов в Alpine,
# авто-выбор HTTP или SSL конфига в зависимости от наличия server.crt + server.key.
# авто-выбор 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
# ======================================================================
@@ -51,14 +63,155 @@ install_ca_certificates() {
}
# #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:2] [TYPE Function]
# #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"
@@ -80,15 +233,24 @@ select_nginx_config() {
echo "[entrypoint] SSL-сертификаты не найдены — используем HTTP-only режим"
echo "[entrypoint] Чтобы включить HTTPS, положите в ${certs_dir}:"
echo "[entrypoint] server.crt — SSL сертификат"
echo "[entrypoint] server.key — приватный ключ"
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
select_nginx_config
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 "$@"