From 1c6b3d6e8afebd024fab9230fc06632050b70e15 Mon Sep 17 00:00:00 2001 From: busya Date: Sat, 4 Jul 2026 17:05:51 +0300 Subject: [PATCH] feat(ssl): add passphrase-protected key and PKCS#12 support in nginx entrypoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.enterprise-clean.example | 21 ++++ build.sh | 2 + docker-compose.enterprise-clean.yml | 4 + docker/frontend.entrypoint.sh | 172 +++++++++++++++++++++++++++- 4 files changed, 194 insertions(+), 5 deletions(-) diff --git a/.env.enterprise-clean.example b/.env.enterprise-clean.example index ceabec61..e7e42b66 100644 --- a/.env.enterprise-clean.example +++ b/.env.enterprise-clean.example @@ -39,10 +39,31 @@ FRONTEND_SSL_PORT=443 # ./certs/server.crt # ./certs/server.key # +# +# Для PKCS#12 — положите server.p12 (контейнер с сертификатом + ключом): +# ./certs/server.p12 +# SSL_KEY_PASSPHRASE=my-passphrase +# (entrypoint извлечёт server.crt и server.key автоматически) +# +# Если приватный ключ зашифрован, укажите пароль: +# SSL_KEY_PASSPHRASE=my-passphrase +# (entrypoint расшифрует ключ перед передачей nginx) +# # Если директория пуста или не существует — сертификаты не устанавливаются, # nginx работает в HTTP-only режиме. CERTS_PATH=./certs +# ====================================================================== +# SSL пароль (опционально) +# ====================================================================== +# Пароль от приватного ключа (если server.key зашифрован) или от .p12 контейнера. +# Оставьте пустым, если ключ не зашифрован. +# Пример: +# SSL_KEY_PASSPHRASE=my-ssl-passphrase +# SSL_KEY_PASSPHRASE= +# shellcheck disable=SC2148 +SSL_KEY_PASSPHRASE= + # ====================================================================== # LLM CA-сертификаты (скачка по URL на старте контейнера) # ====================================================================== diff --git a/build.sh b/build.sh index 12041c19..8fc89133 100755 --- a/build.sh +++ b/build.sh @@ -286,6 +286,8 @@ services: restart: unless-stopped depends_on: - backend + environment: + SSL_KEY_PASSPHRASE: \${SSL_KEY_PASSPHRASE:-} ports: - "\${FRONTEND_HOST_PORT:-8000}:80" - "\${FRONTEND_SSL_PORT:-443}:443" diff --git a/docker-compose.enterprise-clean.yml b/docker-compose.enterprise-clean.yml index 103f10f4..51186960 100644 --- a/docker-compose.enterprise-clean.yml +++ b/docker-compose.enterprise-clean.yml @@ -77,6 +77,10 @@ services: restart: unless-stopped depends_on: - backend + environment: + # Пароль от приватного ключа/PKCS#12 для SSL терминации nginx. + # Опционально: если server.key не зашифрован или используется HTTP — не требуется. + SSL_KEY_PASSPHRASE: ${SSL_KEY_PASSPHRASE:-} ports: - "${FRONTEND_HOST_PORT:-8000}:80" # Если в CERTS_PATH есть server.crt + server.key — nginx включит HTTPS на 443. diff --git a/docker/frontend.entrypoint.sh b/docker/frontend.entrypoint.sh index 2a2d807e..00be62ef 100644 --- a/docker/frontend.entrypoint.sh +++ b/docker/frontend.entrypoint.sh @@ -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 "$@"