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:
@@ -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 "$@"
|
||||
|
||||
Reference in New Issue
Block a user