Files
IdP/install.sh
2026-07-07 17:04:59 +03:00

4588 lines
173 KiB
Bash
Raw Permalink 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.
#!/usr/bin/env bash
# Lendry ID — установка, Nginx, SSL и управление одной командой
#
# Использование:
# chmod +x install.sh && ./install.sh
# ./install.sh --install --api-domain api.example.com --frontend-domain id.example.com \
# --docs-domain docs.example.com --email admin@example.com
# ./install.sh --configure-domains # изменить домены и пересобрать
# ./install.sh --local # локально без Nginx
set -euo pipefail
SCRIPT_VERSION="2.6.5"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR"
NGINX_PREFIX="lendry-id"
NGINX_AVAILABLE="/etc/nginx/sites-available"
NGINX_ENABLED="/etc/nginx/sites-enabled"
CERTBOT_WEBROOT="/var/www/certbot"
LOCAL_CONF_DIR="${ROOT_DIR}/nginx/conf.d"
LOCAL_CERTS_DIR="${ROOT_DIR}/nginx/certs"
CUSTOM_CERT_TARGET_DIR="${LOCAL_CERTS_DIR}/custom"
DEFAULT_CERT_UPLOAD_DIR="${LOCAL_CERTS_DIR}/upload"
COMPOSE_OVERRIDE="${ROOT_DIR}/docker-compose.override.yml"
STATE_FILE="${ROOT_DIR}/.idp-install.json"
INFRA_SECRETS_FILE="${ROOT_DIR}/.idp-infra.secrets"
NON_INTERACTIVE=0
FORCE_ENV="${FORCE_ENV:-0}"
ACTION=""
INSTALL_LOCAL=0
INSTALL_INTRANET=0
OFFLINE="${OFFLINE:-0}"
NGINX_MODE="${NGINX_MODE:-docker}"
SKIP_DOCKER_PRUNE="${SKIP_DOCKER_PRUNE:-0}"
DOCKER_PRUNE_AGGRESSIVE="${DOCKER_PRUNE_AGGRESSIVE:-0}"
# Принудительный Docker Nginx (контейнер lendry-id-nginx) — включается в --fix-all
FORCE_DOCKER_NGINX="${FORCE_DOCKER_NGINX:-0}"
CLI_DOMAIN_API=""
CLI_DOMAIN_FRONTEND=""
CLI_DOMAIN_DOCS=""
CLI_DOMAIN_WS=""
CLI_DOMAIN_MINIO=""
CLI_DOMAIN_MINIO_CONSOLE=""
CLI_EMAIL=""
CLI_SSL_TYPE=""
CLI_CERT_DIR=""
CLI_CERT_FULLCHAIN=""
CLI_CERT_KEY=""
CLI_NGINX_HTTP_PORT=""
CLI_NGINX_HTTPS_PORT=""
NGINX_MODE_CLI=""
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
log() { echo -e "${CYAN}[IdP]${NC} $*"; }
ok() { echo -e "${GREEN}[IdP]${NC} $*"; }
warn() { echo -e "${YELLOW}[IdP]${NC} $*"; }
fail() { echo -e "${RED}[IdP] ОШИБКА:${NC} $*" >&2; exit 1; }
usage() {
cat <<EOF
${BOLD}Lendry ID v${SCRIPT_VERSION}${NC} — установка и управление
./install.sh Интерактивное меню
./install.sh --install Полная установка
./install.sh --fresh-install Полная очистка Docker + установка с нуля
./install.sh --configure-domains Изменить домены / Nginx / SSL
./install.sh --renew-ssl Обновить сертификаты (Let's Encrypt / custom / selfsigned)
./install.sh --restart Перезапуск контейнеров
./install.sh --migrate-rebuild Миграции Prisma + пересборка стека (после git pull)
./install.sh --fix-host-nginx Устарело: восстановить Docker Nginx (lendry-id-nginx)
./install.sh --fix-docker-nginx Остановить system nginx, поднять lendry-id-nginx
./install.sh --docker-prune Только очистка Docker (без пересборки)
./install.sh --fix-proxy Исправить 502: host Nginx + порты 3000/3002/3003
./install.sh --fix-all Комплексное исправление: 502, прокси, порты, Nginx
./install.sh --status Статус сервисов
./install.sh --reset-db Сброс PostgreSQL (новый пароль из .env, данные удалятся)
./install.sh --remove-nginx Удалить конфиги Nginx IdP
${BOLD}Режим локальной сети (без интернета / AD-домены .local .lpr):${NC}
--intranet Локальная сеть: HTTP или самоподписанный HTTPS
--ssl TYPE none | selfsigned | custom | letsencrypt
--cert-dir PATH Папка со своими сертификатами (для --ssl custom)
--cert-fullchain FILE Файл сертификата (fullchain.pem, _wildcard.domain.pem, …)
--cert-key FILE Файл ключа (privkey.pem, _wildcard.domain-key.pem, …)
--nginx-http-port PORT HTTP-порт Nginx (если 80 занят, например 8080)
--nginx-https-port PORT HTTPS-порт Nginx (если 443 занят, например 8443)
--offline Не скачивать Docker из интернета (только если уже установлен)
${BOLD}Домены (можно указывать отдельно):${NC}
--api-domain DOMAIN API / Swagger
--frontend-domain DOMAIN Веб-интерфейс
--docs-domain DOMAIN Документация
--ws-domain DOMAIN WebSocket (пусто = API + /ws)
--minio-domain DOMAIN MinIO S3 (опционально)
--minio-console-domain DOMAIN MinIO Console (опционально)
--email EMAIL Email для Let's Encrypt (только ssl=letsencrypt)
${BOLD}Прочие опции:${NC}
-y, --yes Без интерактивных вопросов
--local localhost:3000/3002/3003 без Nginx
-h, --help Справка
${BOLD}Переменные окружения:${NC}
FORCE_ENV=1 Новые JWT-секреты (пароли БД/MinIO/RabbitMQ сохраняются если volume уже есть)
OFFLINE=1 То же, что --offline
FORCE_ENV=1 При --fresh-install: удалить .env и секреты, создать заново
SKIP_DOCKER_PRUNE=1 Не очищать образы/кеш перед пересборкой
DOCKER_PRUNE_AGGRESSIVE=1 Глубже чистить неиспользуемые образы проекта
COMPOSE_PARALLEL_LIMIT=4 Параллельная сборка сервисов (по умолчанию 4)
${BOLD}Примеры:${NC}
./install.sh --intranet --install --yes \\
--api-domain api.idpmvk.lpr \\
--frontend-domain id.idpmvk.lpr \\
--docs-domain docs.idpmvk.lpr \\
--ssl custom --cert-dir /opt/idp-certs
./install.sh --intranet --install --yes \\
--api-domain api.idpmvk.lpr \\
--frontend-domain id.idpmvk.lpr \\
--docs-domain docs.idpmvk.lpr \\
--ssl none
./install.sh --intranet --ssl selfsigned \\
--api-domain api.idpmvk.lpr --frontend-domain id.idpmvk.lpr \\
--docs-domain docs.idpmvk.lpr
./install.sh --install --yes \\
--api-domain api.id.example.ru \\
--frontend-domain id.example.ru \\
--docs-domain docs.example.ru \\
--ssl letsencrypt --email admin@example.ru
./install.sh --configure-domains
${BOLD}Полная переустановка (очистка Docker + volumes + установка):${NC}
./install.sh --fresh-install --yes \\
--intranet \\
--api-domain api.idpmvk.lpr \\
--frontend-domain sso.idpmvk.lpr \\
--docs-domain docs.idpmvk.lpr \\
--ssl selfsigned
EOF
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
-y | --yes) NON_INTERACTIVE=1; shift ;;
--install) ACTION="install"; shift ;;
--fresh-install) ACTION="fresh-install"; shift ;;
--configure-domains) ACTION="configure-domains"; shift ;;
--renew-ssl) ACTION="renew-ssl"; shift ;;
--restart) ACTION="restart"; shift ;;
--migrate-rebuild) ACTION="migrate-rebuild"; shift ;;
--fix-host-nginx) ACTION="fix-docker-nginx"; shift ;;
--fix-docker-nginx) ACTION="fix-docker-nginx"; shift ;;
--docker-prune) ACTION="docker-prune"; shift ;;
--skip-docker-prune) SKIP_DOCKER_PRUNE=1; shift ;;
--docker-prune-aggressive) DOCKER_PRUNE_AGGRESSIVE=1; shift ;;
--fix-proxy) ACTION="fix-proxy"; shift ;;
--fix-all) ACTION="fix-all"; shift ;;
--status) ACTION="status"; shift ;;
--reset-db) ACTION="reset-db"; shift ;;
--build-apk) ACTION="build-apk"; shift ;;
--remove-nginx) ACTION="remove-nginx"; shift ;;
--local) INSTALL_LOCAL=1; shift ;;
--intranet) INSTALL_INTRANET=1; shift ;;
--offline) OFFLINE=1; shift ;;
--ssl) CLI_SSL_TYPE="${2:-}"; shift 2 ;;
--cert-dir) CLI_CERT_DIR="${2:-}"; shift 2 ;;
--cert-fullchain) CLI_CERT_FULLCHAIN="${2:-}"; shift 2 ;;
--cert-key) CLI_CERT_KEY="${2:-}"; shift 2 ;;
--nginx-mode) NGINX_MODE="${2:-docker}"; NGINX_MODE_CLI="${2:-docker}"; shift 2 ;;
--nginx-http-port) CLI_NGINX_HTTP_PORT="${2:-}"; shift 2 ;;
--nginx-https-port) CLI_NGINX_HTTPS_PORT="${2:-}"; shift 2 ;;
--api-domain) CLI_DOMAIN_API="${2:-}"; shift 2 ;;
--frontend-domain) CLI_DOMAIN_FRONTEND="${2:-}"; shift 2 ;;
--docs-domain) CLI_DOMAIN_DOCS="${2:-}"; shift 2 ;;
--ws-domain) CLI_DOMAIN_WS="${2:-}"; shift 2 ;;
--minio-domain) CLI_DOMAIN_MINIO="${2:-}"; shift 2 ;;
--minio-console-domain) CLI_DOMAIN_MINIO_CONSOLE="${2:-}"; shift 2 ;;
--email) CLI_EMAIL="${2:-}"; shift 2 ;;
-h | --help) usage; exit 0 ;;
*) fail "Неизвестный аргумент: $1 (используйте --help)" ;;
esac
done
}
has_cmd() { command -v "$1" >/dev/null 2>&1; }
detect_os() {
if [[ -f /etc/os-release ]]; then
# shellcheck disable=SC1091
. /etc/os-release
OS_ID="${ID:-unknown}"
OS_VERSION_ID="${VERSION_ID:-}"
else
OS_ID="unknown"
fi
case "$OS_ID" in
ubuntu | debian | linuxmint | pop) PKG_MANAGER="apt" ;;
fedora | rhel | centos | rocky | almalinux) PKG_MANAGER="dnf" ;;
opensuse* | sles) PKG_MANAGER="zypper" ;;
*) PKG_MANAGER="unknown" ;;
esac
log "ОС: ${OS_ID:-unknown} ${OS_VERSION_ID:-} (пакеты: $PKG_MANAGER)"
}
run_root() {
if [[ "$(id -u)" -eq 0 ]]; then
"$@"
elif has_cmd sudo; then
sudo "$@"
else
fail "Нужны права root/sudo для: $*"
fi
}
preflight_checks() {
if [[ "$(uname -s)" != "Linux" ]]; then
warn "ОС не Linux — Nginx будет запущен в Docker (профиль proxy). Убедитесь, что Docker Desktop установлен."
NGINX_MODE="docker"
fi
[[ -f docker-compose.yml ]] || fail "Запустите скрипт из корня репозитория (рядом с docker-compose.yml)"
mkdir -p "$LOCAL_CONF_DIR" "$LOCAL_CERTS_DIR" "$DEFAULT_CERT_UPLOAD_DIR"
}
detect_nginx_mode() {
load_env
if [[ -n "$(env_get NGINX_MODE "")" && "$(env_get NGINX_MODE docker)" != "auto" ]]; then
NGINX_MODE="$(env_get NGINX_MODE docker)"
fi
if [[ "$NGINX_MODE" == "auto" ]]; then
NGINX_MODE="docker"
fi
# Host-режим устарел: без явного --nginx-mode host всегда Docker Nginx
if [[ "$NGINX_MODE" == "host" && "$NGINX_MODE_CLI" != "host" ]]; then
warn "NGINX_MODE=host в .env устарел — используется Docker Nginx (lendry-id-nginx)"
NGINX_MODE="docker"
fi
log "Режим Nginx: ${NGINX_MODE} (docker=контейнер lendry-id-nginx, host=только с --nginx-mode host)"
}
port_in_use() {
local port="$1"
if has_cmd ss; then
ss -tln 2>/dev/null | grep -qE ":${port}[[:space:]]"
elif has_cmd netstat; then
netstat -tln 2>/dev/null | grep -qE ":${port}[[:space:]]"
else
return 1
fi
}
host_nginx_available() {
[[ -d /etc/nginx ]] || has_cmd nginx
}
system_nginx_is_active() {
has_cmd systemctl && systemctl is-active --quiet nginx 2>/dev/null
}
lendry_host_nginx_configured() {
[[ -f "${NGINX_ENABLED}/${NGINX_PREFIX}-frontend.conf" ]] \
|| [[ -L "${NGINX_ENABLED}/${NGINX_PREFIX}-frontend.conf" ]]
}
docker_nginx_container_running() {
docker_cmd ps -q -f name=lendry-id-nginx 2>/dev/null | grep -q .
}
docker_nginx_ports_available() {
local http="${NGINX_HTTP_PORT:-80}"
local https="${NGINX_HTTPS_PORT:-443}"
! port_in_use "$http" && ! port_in_use "$https"
}
# Снимает конфиги IdP с системного Nginx (sites-enabled), не трогая ./nginx/conf.d
disable_host_nginx_lendry() {
log "Отключение конфигов IdP в системном Nginx (/etc/nginx)..."
run_root rm -f "${NGINX_ENABLED}/${NGINX_PREFIX}-"*.conf 2>/dev/null || true
if system_nginx_is_active; then
run_root nginx -t >/dev/null 2>&1 && run_root systemctl reload nginx 2>/dev/null || true
fi
}
# Освобождает 80/443 для lendry-id-nginx: отключает IdP в system nginx и останавливает его.
prepare_docker_nginx_ports() {
load_env
disable_host_nginx_lendry
NGINX_HTTP_PORT="${NGINX_HTTP_PORT:-$(env_get NGINX_HTTP_PORT 80)}"
NGINX_HTTPS_PORT="${NGINX_HTTPS_PORT:-$(env_get NGINX_HTTPS_PORT 443)}"
[[ -n "$CLI_NGINX_HTTP_PORT" ]] && NGINX_HTTP_PORT="$CLI_NGINX_HTTP_PORT"
[[ -n "$CLI_NGINX_HTTPS_PORT" ]] && NGINX_HTTPS_PORT="$CLI_NGINX_HTTPS_PORT"
if system_nginx_is_active \
|| { port_in_use "$NGINX_HTTP_PORT" && host_nginx_available; } \
|| { port_in_use "$NGINX_HTTPS_PORT" && host_nginx_available; }; then
log "Остановка системного Nginx (освобождение портов ${NGINX_HTTP_PORT}/${NGINX_HTTPS_PORT} для lendry-id-nginx)..."
run_root systemctl stop nginx 2>/dev/null || run_root service nginx stop 2>/dev/null || true
sleep 2
fi
env_set NGINX_HTTP_PORT "$NGINX_HTTP_PORT"
env_set NGINX_HTTPS_PORT "$NGINX_HTTPS_PORT"
}
# --fix-all / --install: всегда Docker Nginx (lendry-id-nginx), не системный на 80/443
ensure_docker_nginx_mode_for_fix_all() {
load_env
FORCE_DOCKER_NGINX=1
NGINX_MODE="docker"
env_set NGINX_MODE "docker"
log "Переключение на Docker Nginx (контейнер lendry-id-nginx)..."
prepare_docker_nginx_ports
docker_cmd rm -f lendry-id-nginx 2>/dev/null || true
if port_in_use "$NGINX_HTTP_PORT" || port_in_use "$NGINX_HTTPS_PORT"; then
warn "Порты ${NGINX_HTTP_PORT}/${NGINX_HTTPS_PORT} всё ещё заняты после остановки system nginx"
if has_cmd ss; then
ss -tlnp 2>/dev/null | grep -E ":${NGINX_HTTP_PORT}\s|:${NGINX_HTTPS_PORT}\s" || true
fi
fail "Освободите порты ${NGINX_HTTP_PORT}/${NGINX_HTTPS_PORT} и повторите: ./install.sh --fix-all"
fi
ok "NGINX_MODE=docker — порты ${NGINX_HTTP_PORT}/${NGINX_HTTPS_PORT} готовы для lendry-id-nginx"
}
# Полная установка / fresh-install: IdP в Docker — системный Nginx на 80/443 ломает /idp-api (502).
ensure_docker_nginx_mode_for_install() {
[[ "$INSTALL_LOCAL" -eq 1 ]] && return 0
load_env
[[ -n "$(env_get DOMAIN_API "")" ]] || return 0
if [[ "$NGINX_MODE_CLI" == "host" ]]; then
warn "NGINX_MODE=host (--nginx-mode host) — Docker Nginx не будет использован"
return 0
fi
ensure_docker_nginx_mode_for_fix_all
}
# Docker Nginx на 80/443: не переключаемся на system nginx при занятых портах — освобождаем их.
ensure_docker_nginx_not_conflicting() {
load_env
if [[ "${FORCE_DOCKER_NGINX:-0}" -eq 1 ]] || [[ "$(env_get NGINX_MODE docker)" == "docker" ]]; then
NGINX_MODE="docker"
env_set NGINX_MODE "docker"
prepare_docker_nginx_ports
return 0
fi
if [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
stop_docker_nginx
ensure_host_nginx_stack || true
fi
}
stop_docker_nginx() {
local cid
cid="$(docker_cmd ps -q -f name=lendry-id-nginx 2>/dev/null || true)"
if [[ -n "$cid" ]]; then
log "Останавливаем контейнер lendry-id-nginx (используется системный Nginx)..."
docker_cmd compose --env-file .env --profile proxy stop nginx 2>/dev/null || true
docker_cmd rm -f lendry-id-nginx 2>/dev/null || true
ok "Docker Nginx остановлен"
fi
}
# NGINX_MODE=host: системный Nginx на 80/443, Docker Nginx не используется.
ensure_host_nginx_stack() {
load_env
[[ "$(env_get NGINX_MODE docker)" == "host" ]] || return 0
[[ -n "$(env_get DOMAIN_API "")" ]] || return 0
[[ "$(env_get INSTALL_MODE local)" == "local" ]] && return 0
stop_docker_nginx
log "Синхронизация системного Nginx (NGINX_MODE=host)..."
refresh_host_nginx_configs || true
recreate_host_port_services || true
ensure_host_upstreams || true
if ! host_nginx_available; then
fail "NGINX_MODE=host, но системный Nginx не установлен. Установите nginx или переключите NGINX_MODE=docker"
fi
if ! system_nginx_is_active; then
log "Запуск системного Nginx (освободили порты после остановки lendry-id-nginx)..."
run_root systemctl start nginx 2>/dev/null || run_root service nginx start 2>/dev/null || \
fail "Не удалось запустить system nginx — проверьте: sudo systemctl status nginx"
fi
if run_root nginx -t >/dev/null 2>&1; then
run_root systemctl reload nginx 2>/dev/null || run_root service nginx reload 2>/dev/null || true
ok "Системный Nginx активен и перезагружен"
else
warn "nginx -t не прошёл — проверьте /etc/nginx/sites-enabled/${NGINX_PREFIX}-*.conf"
run_root nginx -t 2>&1 | sed 's/^/ /' || true
return 1
fi
if docker_nginx_container_running; then
fail "Конфликт: lendry-id-nginx всё ещё запущен при NGINX_MODE=host"
fi
}
action_fix_host_nginx() {
warn "--fix-host-nginx устарел — используется Docker Nginx (lendry-id-nginx)"
action_fix_docker_nginx
}
action_fix_docker_nginx() {
[[ -f .env ]] || fail "Файл .env не найден"
load_env
NON_INTERACTIVE=1
log "Восстановление Docker Nginx (lendry-id-nginx)..."
ensure_docker_nginx_mode_for_fix_all
ensure_docker_nginx_configs
prepare_compose_stack
ensure_nginx_after_stack_rebuild || fail "Не удалось запустить lendry-id-nginx"
sync_nginx_after_api_gateway_restart || true
sync_nginx_after_edge_restart || true
reload_docker_nginx || true
post_deploy_proxy_verify 0 || warn "Проверка прокси не прошла — выполните: ./install.sh --fix-all"
diagnose_common_issues || true
ok "Docker Nginx (lendry-id-nginx) восстановлен — проверьте сайт в браузере"
show_status
}
reload_docker_nginx() {
docker_nginx_container_running || return 0
log "Перезагрузка конфигурации Docker Nginx..."
if docker_cmd compose --env-file .env --profile proxy exec -T nginx nginx -s reload 2>/dev/null; then
ok "Docker Nginx: конфигурация перезагружена"
else
warn "nginx -s reload не удался — перезапуск контейнера lendry-id-nginx"
docker_cmd compose --env-file .env --profile proxy restart nginx 2>/dev/null || true
fi
}
# Ждёт HTTP /health на опубликованном порту api-gateway (127.0.0.1:3000).
wait_for_api_gateway_http() {
local max_attempts="${1:-60}"
local attempt=0
while [[ $attempt -lt $max_attempts ]]; do
if curl -fsS --max-time 3 http://127.0.0.1:3000/health 2>/dev/null | grep -q '"status"'; then
return 0
fi
attempt=$((attempt + 1))
sleep 2
done
return 1
}
# После recreate api-gateway nginx должен сбросить DNS/keepalive — иначе 502 на старый IP.
sync_nginx_after_api_gateway_restart() {
docker_nginx_container_running || return 0
ensure_docker_nginx_configs || true
log "Синхронизация Docker Nginx с api-gateway после пересборки..."
wait_for_service "api-gateway" 120 || warn "api-gateway не стал healthy в срок"
wait_for_api_gateway_http 90 || warn "api-gateway /health на :3000 не отвечает"
sleep 2
reload_docker_nginx
if ! verify_nginx_reaches_api_gateway 24 5; then
warn "Reload не помог — пересоздаём lendry-id-nginx..."
recreate_docker_nginx
verify_nginx_reaches_api_gateway 12 5 || warn "Nginx → api-gateway: проверка не прошла, см. docker compose logs nginx api-gateway"
fi
}
# После recreate frontend/docs nginx должен перерезолвить lendry-id-frontend — иначе 502 на / (корень SSO).
sync_nginx_after_edge_restart() {
docker_nginx_container_running || return 0
log "Синхронизация Docker Nginx с frontend после пересборки..."
wait_for_service "frontend" 120 || warn "frontend не стал healthy в срок"
sleep 2
reload_docker_nginx
if ! verify_nginx_reaches_frontend 12 3; then
warn "Nginx → frontend: reload не помог — пересоздаём lendry-id-nginx..."
recreate_docker_nginx
verify_nginx_reaches_frontend 12 3 || warn "Nginx → frontend: проверка не прошла, см. docker compose logs nginx frontend"
fi
}
recreate_docker_nginx() {
compose_uses_proxy_profile || return 0
ensure_nginx_gateway_watch_script
build_compose_stack_cmd
log "Пересоздание контейнера lendry-id-nginx (подхват конфигов из ./nginx/conf.d)..."
docker_cmd "${COMPOSE_STACK_CMD[@]}" up -d --force-recreate nginx
wait_for_service "nginx" 60
}
# После пересборки стека (п.11 migrate-rebuild): Nginx согласно NGINX_MODE.
ensure_nginx_after_stack_rebuild() {
load_env
prepare_compose_stack
build_compose_stack_cmd
[[ -n "$(env_get DOMAIN_API "")" ]] || return 0
[[ "$(env_get INSTALL_MODE local)" == "local" ]] && return 0
if [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
ensure_host_nginx_stack
return $?
fi
if [[ "$(env_get NGINX_MODE docker)" == "docker" ]]; then
log "Запуск и синхронизация Docker Nginx (lendry-id-nginx)..."
ensure_docker_nginx_configs
ensure_nginx_gateway_watch_script
patch_legacy_nginx_proxy_pass || true
if compose_uses_proxy_profile; then
if docker_nginx_container_running; then
recreate_docker_nginx
else
compose_build_and_up nginx
wait_for_service "nginx" 60
fi
elif docker_nginx_ports_available; then
warn "compose_uses_proxy_profile=false — принудительный запуск nginx..."
compose_build_and_up nginx
wait_for_service "nginx" 60
else
warn "Порты ${NGINX_HTTP_PORT:-80}/${NGINX_HTTPS_PORT:-443} заняты — Docker Nginx не запущен"
warn "Выполните: ./install.sh --fix-all"
return 1
fi
sync_nginx_after_api_gateway_restart || true
sync_nginx_after_edge_restart || true
reload_docker_nginx || true
if ! docker_nginx_container_running; then
fail "Контейнер lendry-id-nginx не запущен. Выполните: ./install.sh --fix-all"
fi
ok "Docker Nginx (lendry-id-nginx) запущен"
return 0
fi
warn "NGINX_MODE=$(env_get NGINX_MODE auto) — пропуск настройки Nginx"
}
ensure_nginx_gateway_watch_script() {
local watch_script="${ROOT_DIR}/infra/nginx/99-idp-gateway-watch.sh"
[[ -f "$watch_script" ]] || return 0
chmod +x "$watch_script" 2>/dev/null || true
}
# Проба /idp-api/health изнутри nginx (HTTPS при SSL — иначе HTTP даёт 301 на :80).
nginx_probe_idp_api_health_from_container() {
local frontend_domain="$1"
local use_ssl ssl_type
load_env
ssl_type="$(env_get SSL_TYPE none)"
use_ssl="$(env_get USE_NGINX_SSL false)"
build_compose_stack_cmd
if [[ "$use_ssl" == "true" || "$ssl_type" != "none" ]]; then
body="$(docker_cmd "${COMPOSE_STACK_CMD[@]}" exec -T nginx \
wget -qO- --timeout=6 --no-check-certificate \
--header="Host: ${frontend_domain}" \
"https://127.0.0.1/idp-api/health" 2>/dev/null || true)"
if [[ "$body" == *'"status"'* ]]; then
printf '%s' "$body"
return 0
fi
body="$(docker_cmd "${COMPOSE_STACK_CMD[@]}" exec -T nginx \
wget -qO- --timeout=6 \
--header="Host: ${frontend_domain}" \
"http://127.0.0.1/idp-api/health" 2>/dev/null || true)"
printf '%s' "$body"
return 0
fi
docker_cmd "${COMPOSE_STACK_CMD[@]}" exec -T nginx \
wget -qO- --timeout=6 \
--header="Host: ${frontend_domain}" \
"http://127.0.0.1/idp-api/health" 2>/dev/null || true
}
post_deploy_proxy_verify() {
local strict="${1:-0}"
compose_uses_proxy_profile || return 0
wait_for_service "nginx" 60
wait_for_api_grpc_ready 90 || {
[[ "$strict" -eq 1 ]] && fail "api-gateway → sso-core gRPC не готов (/health/ready). Проверьте: docker compose logs sso-core api-gateway"
warn "api-gateway → sso-core gRPC не готов (/health/ready)"
}
log "Проверка Docker Nginx → api-gateway (DNS + /idp-api)..."
reload_docker_nginx
sleep 2
verify_nginx_reaches_api_gateway 24 5 || {
reload_docker_nginx
verify_nginx_reaches_api_gateway 12 5 || {
[[ "$strict" -eq 1 ]] && fail "Docker Nginx не достучался до api-gateway:3000 — см. docker compose logs nginx api-gateway"
warn "Docker Nginx → api-gateway: проверка не прошла"
}
}
verify_nginx_reaches_media_ws || true
verify_nginx_reaches_frontend 12 3 || {
[[ "$strict" -eq 1 ]] && fail "Docker Nginx не проксирует / на frontend — см. docker compose logs nginx frontend"
warn "Nginx → frontend: проверка не прошла"
}
if [[ "$(env_get USE_NGINX_SSL false)" == "true" ]]; then
check_sso_idp_api_proxy || {
[[ "$strict" -eq 1 ]] && fail "SSO /idp-api/health → 502. Выполните: ./install.sh --fix-all"
warn "SSO /idp-api/health не прошёл — выполните ./install.sh --fix-all"
}
check_sso_frontend_root || {
[[ "$strict" -eq 1 ]] && fail "SSO / → 502. Nginx не проксирует главную на frontend. Выполните: ./install.sh --fix-all"
warn "SSO / (главная) не прошла — выполните ./install.sh --fix-all"
}
fi
}
# Поэтапный запуск: nginx стартует ПОСЛЕ healthy api-gateway — без 502 на первом запросе.
deploy_phased_stack() {
log "Фаза 1/5: PostgreSQL, Redis, RabbitMQ, MinIO, LDAP..."
compose_build_and_up postgres redis rabbitmq minio ldap-auth
wait_for_service "ldap-auth" 90
log "Фаза 2/5: sso-core..."
SKIP_DOCKER_PRUNE=1
compose_build_and_up sso-core
wait_for_service "sso-core" 120
log "Фаза 3/5: api-gateway..."
compose_build_and_up api-gateway
wait_for_service "api-gateway" 90
wait_for_api_grpc_ready 90 || warn "gRPC api-gateway → sso-core не готов — API может отдавать 502"
log "Фаза 4/5: frontend, docs, media-ws..."
compose_build_and_up frontend docs media-ws
wait_for_service "frontend" 90
wait_for_service "docs" 60
log "Фаза 5/5: Docker Nginx (после готовности backend)..."
ensure_nginx_gateway_watch_script
compose_build_and_up nginx
post_deploy_proxy_verify 1
}
prepare_compose_stack() {
resolve_nginx_mode_and_ports
write_compose_override
ensure_docker_nginx_not_conflicting
}
resolve_nginx_mode_and_ports() {
load_env
if [[ "${FORCE_DOCKER_NGINX:-0}" -eq 1 ]]; then
NGINX_MODE="docker"
NGINX_HTTP_PORT="${NGINX_HTTP_PORT:-$(env_get NGINX_HTTP_PORT 80)}"
NGINX_HTTPS_PORT="${NGINX_HTTPS_PORT:-$(env_get NGINX_HTTPS_PORT 443)}"
[[ -n "$CLI_NGINX_HTTP_PORT" ]] && NGINX_HTTP_PORT="$CLI_NGINX_HTTP_PORT"
[[ -n "$CLI_NGINX_HTTPS_PORT" ]] && NGINX_HTTPS_PORT="$CLI_NGINX_HTTPS_PORT"
env_set NGINX_MODE "docker"
env_set NGINX_HTTP_PORT "${NGINX_HTTP_PORT}"
env_set NGINX_HTTPS_PORT "${NGINX_HTTPS_PORT}"
log "Nginx: mode=docker (принудительно), HTTP=${NGINX_HTTP_PORT}, HTTPS=${NGINX_HTTPS_PORT}"
return 0
fi
detect_nginx_mode
if [[ "$NGINX_MODE" == "docker" ]]; then
prepare_docker_nginx_ports
fi
if [[ -n "$CLI_NGINX_HTTP_PORT" ]]; then
NGINX_HTTP_PORT="$CLI_NGINX_HTTP_PORT"
env_set NGINX_HTTP_PORT "$NGINX_HTTP_PORT"
fi
if [[ -n "$CLI_NGINX_HTTPS_PORT" ]]; then
NGINX_HTTPS_PORT="$CLI_NGINX_HTTPS_PORT"
env_set NGINX_HTTPS_PORT "$NGINX_HTTPS_PORT"
fi
NGINX_HTTP_PORT="${NGINX_HTTP_PORT:-$(env_get NGINX_HTTP_PORT 80)}"
NGINX_HTTPS_PORT="${NGINX_HTTPS_PORT:-$(env_get NGINX_HTTPS_PORT 443)}"
if [[ "$NGINX_MODE" == "docker" ]]; then
if port_in_use "$NGINX_HTTP_PORT" || port_in_use "$NGINX_HTTPS_PORT"; then
if [[ "$NGINX_HTTP_PORT" == "80" && "$NON_INTERACTIVE" -eq 1 ]]; then
warn "Порт 80/443 занят — Docker Nginx будет на 8080/8443"
NGINX_HTTP_PORT="8080"
NGINX_HTTPS_PORT="8443"
env_set NGINX_HTTP_PORT "8080"
env_set NGINX_HTTPS_PORT "8443"
elif [[ "$NGINX_HTTP_PORT" == "80" ]]; then
echo ""
warn "Порт 80 или 443 уже занят на этом сервере."
echo " 1) Остановить системный Nginx и использовать Docker на 80/443 (рекомендуется)"
echo " 2) Docker Nginx на портах 8080 / 8443"
echo " 3) Отмена — освободите 80/443 вручную и повторите"
local port_choice
read -r -p "Выберите [1-3] [1]: " port_choice
port_choice="${port_choice:-1}"
case "$port_choice" in
1)
prepare_docker_nginx_ports
;;
2)
NGINX_HTTP_PORT="8080"
NGINX_HTTPS_PORT="8443"
env_set NGINX_HTTP_PORT "8080"
env_set NGINX_HTTPS_PORT "8443"
;;
*)
fail "Освободите порт 80: sudo systemctl stop nginx или укажите --nginx-http-port 8080"
;;
esac
else
fail "Порт ${NGINX_HTTP_PORT} или ${NGINX_HTTPS_PORT} занят. Освободите его или укажите другие: --nginx-http-port 8080"
fi
fi
fi
env_set NGINX_MODE "$NGINX_MODE"
env_set NGINX_HTTP_PORT "${NGINX_HTTP_PORT:-80}"
env_set NGINX_HTTPS_PORT "${NGINX_HTTPS_PORT:-443}"
log "Nginx: mode=${NGINX_MODE}, HTTP=${NGINX_HTTP_PORT}, HTTPS=${NGINX_HTTPS_PORT}"
}
compose_uses_proxy_profile() {
load_env
[[ -n "$(env_get DOMAIN_API "")" ]] || return 1
[[ "$(env_get INSTALL_MODE local)" == "local" ]] && return 1
[[ "$(env_get NGINX_MODE docker)" == "docker" ]] || return 1
if ! docker_nginx_ports_available; then
return 1
fi
return 0
}
# Заполняет глобальный массив COMPOSE_STACK_CMD для docker compose
build_compose_stack_cmd() {
COMPOSE_STACK_CMD=(compose --env-file .env)
if compose_uses_proxy_profile; then
COMPOSE_STACK_CMD+=(--profile proxy)
fi
}
install_packages_apt() {
run_root apt-get update -qq
run_root apt-get install -y -qq ca-certificates curl git openssl gnupg lsb-release jq
}
install_packages_dnf() {
run_root dnf install -y ca-certificates curl git openssl jq
}
install_base_packages() {
log "Проверка базовых пакетов..."
case "$PKG_MANAGER" in
apt) install_packages_apt ;;
dnf) install_packages_dnf ;;
*)
warn "Автоустановка пакетов для $OS_ID не поддерживается. Нужны: curl, git, openssl, jq"
;;
esac
}
install_docker() {
if has_cmd docker && docker info >/dev/null 2>&1; then
ok "Docker: $(docker --version)"
return
fi
if has_cmd docker && ! docker info >/dev/null 2>&1; then
warn "Docker найден, но daemon недоступен. Пробуем запустить..."
run_root systemctl start docker 2>/dev/null || true
if docker info >/dev/null 2>&1; then
ok "Docker daemon запущен"
return
fi
fi
if [[ "$OFFLINE" -eq 1 || "$INSTALL_INTRANET" -eq 1 ]]; then
fail "Docker не установлен или не запущен. В локальной сети установите Docker вручную (offline-пакет / Docker Desktop) и повторите запуск."
fi
log "Установка Docker (нужен доступ в интернет)..."
curl -fsSL https://get.docker.com | run_root sh
run_root systemctl enable docker >/dev/null 2>&1 || true
run_root systemctl start docker >/dev/null 2>&1 || true
if [[ "$(id -u)" -ne 0 ]] && has_cmd sudo; then
if ! groups "$USER" | grep -q docker; then
run_root usermod -aG docker "$USER" || true
warn "Пользователь $USER добавлен в группу docker. Может потребоваться перелогиниться."
export DOCKER_SUGGESTED_GROUP=1
fi
fi
ok "Docker установлен"
}
check_docker_compose() {
if docker compose version >/dev/null 2>&1; then
ok "Docker Compose: $(docker compose version --short 2>/dev/null || docker compose version)"
return
fi
fail "Docker Compose v2 не найден. Переустановите Docker."
}
docker_cmd() {
if docker info >/dev/null 2>&1; then
docker "$@"
elif has_cmd sudo && sudo docker info >/dev/null 2>&1; then
sudo docker "$@"
else
fail "Docker daemon недоступен. Запустите: sudo systemctl start docker"
fi
}
configure_docker_build_env() {
export DOCKER_BUILDKIT=1
export COMPOSE_DOCKER_CLI_BUILD=1
export BUILDKIT_PROGRESS="${BUILDKIT_PROGRESS:-auto}"
export COMPOSE_PARALLEL_LIMIT="${COMPOSE_PARALLEL_LIMIT:-4}"
}
docker_disk_usage_brief() {
docker_cmd system df 2>/dev/null | sed 's/^/ /' || true
}
docker_remove_stopped_project_containers() {
local project cid
project="$(compose_project_name)"
while IFS= read -r cid; do
[[ -z "$cid" ]] && continue
docker_cmd rm -f "$cid" 2>/dev/null || true
done < <(docker_cmd ps -aq --filter "label=com.docker.compose.project=${project}" --filter "status=exited" 2>/dev/null || true)
}
docker_prune_stale_project_images() {
local project="$1"
local aggressive="${2:-0}"
local id repo
while IFS= read -r id; do
[[ -z "$id" ]] && continue
if docker_cmd ps -a --filter "ancestor=${id}" -q 2>/dev/null | grep -q .; then
continue
fi
docker_cmd rmi -f "$id" 2>/dev/null || true
done < <(docker_cmd images --filter "label=com.docker.compose.project=${project}" -q 2>/dev/null | sort -u || true)
if [[ "$aggressive" == "1" ]]; then
while IFS= read -r repo id; do
[[ -z "$id" || "$repo" == "<none>" ]] && continue
case "$repo" in
lendry-id-* | idservice-* | "${project}"-*)
if ! docker_cmd ps -a --filter "ancestor=${id}" -q 2>/dev/null | grep -q .; then
docker_cmd rmi -f "$id" 2>/dev/null || true
fi
;;
esac
done < <(docker_cmd images --format '{{.Repository}} {{.ID}}' 2>/dev/null || true)
fi
}
docker_prune_before_rebuild() {
local aggressive="${1:-0}"
log "Очистка Docker перед пересборкой (volumes данных не удаляются)..."
build_compose_stack_cmd
if [[ "$aggressive" == "1" ]]; then
log " Режим aggressive: остановка стека и удаление orphan-контейнеров..."
docker_cmd "${COMPOSE_STACK_CMD[@]}" down --remove-orphans 2>/dev/null || true
else
log " Удаление остановленных контейнеров проекта..."
docker_remove_stopped_project_containers
docker_cmd container prune -f --filter "until=24h" 2>/dev/null || true
fi
log " Удаление dangling-образов и старого build cache..."
docker_cmd image prune -f 2>/dev/null || true
if docker_cmd builder prune --help 2>/dev/null | grep -q 'until='; then
docker_cmd builder prune -f --filter "until=168h" 2>/dev/null || \
docker_cmd builder prune -f 2>/dev/null || true
else
docker_cmd builder prune -f 2>/dev/null || true
fi
log " Удаление неиспользуемых образов проекта..."
docker_prune_stale_project_images "$(compose_project_name)" "$aggressive"
ok "Docker очищен перед сборкой"
docker_disk_usage_brief
}
docker_prune_after_rebuild() {
docker_cmd image prune -f 2>/dev/null || true
}
compose_build_parallel_flags() {
COMPOSE_BUILD_PARALLEL=()
if docker_cmd compose build --help 2>/dev/null | grep -q -- '--parallel'; then
COMPOSE_BUILD_PARALLEL=(--parallel)
fi
}
compose_build_and_up() {
local -a services=()
if [[ $# -gt 0 ]]; then
services=("$@")
fi
configure_docker_build_env
ensure_docker_nginx_not_conflicting
build_compose_stack_cmd
compose_build_parallel_flags
if [[ ${#services[@]} -gt 0 ]]; then
local -a filtered=()
local svc
for svc in "${services[@]}"; do
if [[ "$svc" == "nginx" ]] && ! compose_uses_proxy_profile; then
warn "Пропуск сервиса nginx: порты заняты или NGINX_MODE=host — используйте системный Nginx"
continue
fi
filtered+=("$svc")
done
services=("${filtered[@]}")
if [[ ${#services[@]} -eq 0 ]]; then
warn "Нет сервисов для запуска после проверки конфликта Nginx"
return 0
fi
fi
if [[ "${SKIP_DOCKER_PRUNE:-0}" -ne 1 ]]; then
docker_prune_before_rebuild "${DOCKER_PRUNE_AGGRESSIVE:-0}"
fi
if [[ ${#services[@]} -gt 0 ]]; then
log "Сборка сервисов: ${services[*]} (BuildKit, limit=${COMPOSE_PARALLEL_LIMIT})..."
docker_cmd "${COMPOSE_STACK_CMD[@]}" build "${COMPOSE_BUILD_PARALLEL[@]}" "${services[@]}"
log "Запуск сервисов..."
docker_cmd "${COMPOSE_STACK_CMD[@]}" up -d --remove-orphans "${services[@]}"
else
log "Сборка образов (BuildKit, параллельно, limit=${COMPOSE_PARALLEL_LIMIT})..."
docker_cmd "${COMPOSE_STACK_CMD[@]}" build "${COMPOSE_BUILD_PARALLEL[@]}"
log "Запуск контейнеров..."
docker_cmd "${COMPOSE_STACK_CMD[@]}" up -d --remove-orphans
fi
ensure_docker_nginx_not_conflicting
if [[ "${SKIP_DOCKER_PRUNE:-0}" -ne 1 ]]; then
docker_prune_after_rebuild
fi
local svc
for svc in "${services[@]}"; do
case "$svc" in
api-gateway) sync_nginx_after_api_gateway_restart ;;
frontend|docs)
wait_for_service "$svc" 90
sync_nginx_after_edge_restart
;;
media-ws)
wait_for_service "media-ws" 60
reload_docker_nginx
;;
esac
done
}
action_docker_prune() {
load_env
prepare_compose_stack
SKIP_DOCKER_PRUNE=0
docker_prune_before_rebuild "${DOCKER_PRUNE_AGGRESSIVE:-1}"
ok "Очистка Docker завершена"
}
install_nginx_host() {
if has_cmd nginx; then
ok "Nginx уже установлен"
return
fi
log "Установка Nginx..."
case "$PKG_MANAGER" in
apt) run_root apt-get install -y -qq nginx ;;
dnf) run_root dnf install -y nginx ;;
*) fail "Автоустановка Nginx для $OS_ID не поддерживается. Установите nginx вручную или используйте --nginx-mode docker" ;;
esac
run_root systemctl enable nginx >/dev/null 2>&1 || true
ok "Nginx установлен"
}
install_certbot() {
if has_cmd certbot; then
ok "Certbot уже установлен"
return
fi
if [[ "$OFFLINE" -eq 1 || "$INSTALL_INTRANET" -eq 1 ]]; then
fail "Certbot недоступен offline. Для локальной сети используйте --ssl none или --ssl selfsigned"
fi
log "Установка Certbot..."
case "$PKG_MANAGER" in
apt) run_root apt-get install -y -qq certbot python3-certbot-nginx ;;
dnf) run_root dnf install -y certbot ;;
*) fail "Автоустановка Certbot для $OS_ID не поддерживается" ;;
esac
run_root mkdir -p "$CERTBOT_WEBROOT"
ok "Certbot установлен"
}
install_nginx_certbot() {
if [[ "$NGINX_MODE" == "docker" ]]; then
ok "Nginx в Docker — установка системного nginx не требуется"
return
fi
install_nginx_host
}
setup_firewall() {
if ! has_cmd ufw; then
warn "UFW не найден — пропускаем настройку фаервола"
return
fi
if ! run_root ufw status 2>/dev/null | grep -q "Status: active"; then
warn "UFW не активен — откройте порты 80 и 443 вручную при необходимости"
return
fi
log "Настройка UFW (80, 443, SSH)..."
run_root ufw allow 22/tcp >/dev/null 2>&1 || true
run_root ufw allow 80/tcp >/dev/null 2>&1 || true
run_root ufw allow 443/tcp >/dev/null 2>&1 || true
ok "Порты 80 и 443 разрешены в UFW"
}
rand_secret() {
local length="${1:-48}"
if has_cmd openssl; then
openssl rand -base64 64 | tr -dc 'A-Za-z0-9' | head -c "$length"
else
tr -dc 'A-Za-z0-9' </dev/urandom | head -c "$length"
fi
}
prompt() {
local var_name="$1"
local question="$2"
local default="$3"
local value
if [[ "$NON_INTERACTIVE" -eq 1 ]]; then
printf -v "$var_name" '%s' "$default"
return
fi
read -r -p "$question [$default]: " value
if [[ -z "$value" ]]; then
value="$default"
fi
printf -v "$var_name" '%s' "$value"
}
prompt_optional() {
local var_name="$1"
local question="$2"
local default="$3"
local value
if [[ "$NON_INTERACTIVE" -eq 1 ]]; then
printf -v "$var_name" '%s' "$default"
return
fi
if [[ -n "$default" ]]; then
read -r -p "$question [$default, Enter=пропустить]: " value
if [[ -z "$value" ]]; then
value="$default"
fi
else
read -r -p "$question [Enter=пропустить]: " value
fi
printf -v "$var_name" '%s' "$value"
}
validate_domain() {
local domain="$1"
[[ -z "$domain" ]] && return 0
[[ "$domain" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$ ]]
}
normalize_domain() {
local d="${1:-}"
d="${d#https://}"
d="${d#http://}"
d="${d#wss://}"
d="${d#ws://}"
d="${d%%/*}"
d="${d%%:*}"
echo "$d"
}
load_env() {
if [[ -f .env ]]; then
# shellcheck disable=SC1091
set -a && source .env && set +a
fi
NGINX_MODE="${NGINX_MODE:-docker}"
}
env_get() {
local key="$1"
local default="${2:-}"
if [[ -f .env ]] && grep -q "^${key}=" .env; then
grep "^${key}=" .env | head -n1 | cut -d= -f2-
else
echo "$default"
fi
}
env_set() {
local key="$1"
local value="$2"
local escaped
escaped="$(printf '%s' "$value" | sed 's/[&/\]/\\&/g')"
if [[ -f .env ]] && grep -q "^${key}=" .env; then
sed -i "s|^${key}=.*|${key}=${escaped}|" .env
elif [[ -f .env ]]; then
printf '%s=%s\n' "$key" "$value" >>.env
else
printf '%s=%s\n' "$key" "$value" >.env
fi
chmod 600 .env 2>/dev/null || true
}
derive_public_urls() {
local ssl_type="${1:-none}"
if [[ "$INSTALL_LOCAL" -eq 1 ]]; then
if [[ -z "${DOMAIN_API:-}" && -z "${DOMAIN_FRONTEND:-}" ]]; then
PUBLIC_API_URL="http://localhost:3000"
PUBLIC_FRONTEND_URL="http://localhost:3002"
PUBLIC_DOCS_URL="http://localhost:3003"
PUBLIC_WS_URL="ws://localhost:8085/ws"
MINIO_PUBLIC_ENDPOINT="${MINIO_PUBLIC_ENDPOINT:-localhost:9000}"
MINIO_USE_SSL="false"
USE_NGINX_SSL="false"
SSL_TYPE="none"
INSTALL_MODE="local"
return
fi
fi
local scheme="http"
[[ "$ssl_type" == "letsencrypt" || "$ssl_type" == "selfsigned" || "$ssl_type" == "custom" ]] && scheme="https"
PUBLIC_API_URL="${scheme}://${DOMAIN_API}"
PUBLIC_FRONTEND_URL="${scheme}://${DOMAIN_FRONTEND}"
PUBLIC_DOCS_URL="${scheme}://${DOMAIN_DOCS}"
if [[ -n "${DOMAIN_WS:-}" && "${DOMAIN_WS}" != "${DOMAIN_API}" ]]; then
if [[ "$scheme" == "https" ]]; then
PUBLIC_WS_URL="wss://${DOMAIN_WS}/ws"
else
PUBLIC_WS_URL="ws://${DOMAIN_WS}/ws"
fi
else
if [[ "$scheme" == "https" ]]; then
PUBLIC_WS_URL="wss://${DOMAIN_API}/ws"
else
PUBLIC_WS_URL="ws://${DOMAIN_API}/ws"
fi
fi
if [[ -n "${DOMAIN_MINIO:-}" ]]; then
MINIO_PUBLIC_ENDPOINT="${DOMAIN_MINIO}"
MINIO_USE_SSL=$([[ "$scheme" == "https" ]] && echo true || echo false)
fi
if [[ "$ssl_type" == "selfsigned" && -n "${DOMAIN_FRONTEND:-}" ]]; then
if [[ -z "${DOMAIN_API:-}" || "${DOMAIN_API}" == "${DOMAIN_FRONTEND}" ]]; then
apply_same_origin_api_proxy_urls "$scheme"
fi
fi
if [[ "$ssl_type" == "letsencrypt" || "$ssl_type" == "selfsigned" || "$ssl_type" == "custom" ]]; then
env_set FEDCM_COOKIE_SECURE "true"
local fedcm_apex
fedcm_apex="$(registrable_domain "${DOMAIN_FRONTEND:-$DOMAIN_API}")"
if [[ -n "$fedcm_apex" && "$fedcm_apex" != "localhost" ]]; then
env_set FEDCM_COOKIE_DOMAIN ".${fedcm_apex}"
fi
fi
SSL_TYPE="$ssl_type"
USE_NGINX_SSL=$([[ "$ssl_type" == "none" ]] && echo false || echo true)
if [[ "$INSTALL_LOCAL" -eq 1 ]]; then
INSTALL_MODE="local"
elif [[ "$INSTALL_INTRANET" -eq 1 || "$ssl_type" == "none" || "$ssl_type" == "selfsigned" || "$ssl_type" == "custom" ]]; then
INSTALL_MODE="intranet"
elif [[ -n "${DOMAIN_API:-}" ]]; then
INSTALL_MODE="production"
else
INSTALL_MODE="local"
fi
}
apply_same_origin_api_proxy_urls() {
local scheme="${1:-https}"
PUBLIC_API_URL="${PUBLIC_FRONTEND_URL%/}/idp-api"
if [[ -z "${DOMAIN_WS:-}" || "${DOMAIN_WS}" == "${DOMAIN_API}" ]]; then
if [[ "$scheme" == "https" ]]; then
PUBLIC_WS_URL="wss://${DOMAIN_FRONTEND}/idp-api/ws"
else
PUBLIC_WS_URL="ws://${DOMAIN_FRONTEND}/idp-api/ws"
fi
fi
}
# Split-domain (api.xxx + sso.xxx): PUBLIC_API_URL должен быть https://api.xxx,
# а не https://sso.xxx/idp-api — иначе фронтенд шлёт запросы не туда.
ensure_split_domain_public_urls() {
load_env
local api_domain frontend_domain public_api ssl_type scheme ws_url
api_domain="$(env_get DOMAIN_API "")"
frontend_domain="$(env_get DOMAIN_FRONTEND "")"
public_api="$(env_get PUBLIC_API_URL "")"
ssl_type="$(env_get SSL_TYPE none)"
[[ -n "$api_domain" && -n "$frontend_domain" ]] || return 0
[[ "$api_domain" != "$frontend_domain" ]] || return 0
if [[ "$public_api" != *"/idp-api"* ]]; then
return 0
fi
scheme="http"
[[ "$ssl_type" == "letsencrypt" || "$ssl_type" == "selfsigned" || "$ssl_type" == "custom" ]] && scheme="https"
warn "PUBLIC_API_URL=${public_api} — некорректно для split-domain (API и SSO на разных доменах)"
PUBLIC_API_URL="${scheme}://${api_domain}"
env_set PUBLIC_API_URL "${PUBLIC_API_URL}"
ws_url="$(env_get PUBLIC_WS_URL "")"
if [[ -z "$ws_url" || "$ws_url" == *"/idp-api"* ]]; then
if [[ "$scheme" == "https" ]]; then
PUBLIC_WS_URL="wss://${api_domain}/ws"
else
PUBLIC_WS_URL="ws://${api_domain}/ws"
fi
env_set PUBLIC_WS_URL "${PUBLIC_WS_URL}"
fi
env_set INTERNAL_API_URL "http://api-gateway:3000"
ok "Исправлено: PUBLIC_API_URL=${PUBLIC_API_URL}, PUBLIC_WS_URL=$(env_get PUBLIC_WS_URL "")"
}
choose_ssl_type_interactive() {
local default="${1:-none}"
if [[ -n "$CLI_SSL_TYPE" ]]; then
SSL_TYPE="$CLI_SSL_TYPE"
return
fi
if [[ "$NON_INTERACTIVE" -eq 1 ]]; then
if [[ "$INSTALL_INTRANET" -eq 1 ]]; then
SSL_TYPE="${default:-none}"
else
SSL_TYPE="${default:-letsencrypt}"
fi
return
fi
echo ""
echo -e "${BOLD}Тип SSL / доступ:${NC}"
echo " 1) HTTP — локальная сеть, без сертификатов (http://api.idpmvk.lpr)"
echo " 2) HTTPS самоподписанный — локальная сеть, браузер покажет предупреждение"
echo " 3) HTTPS Let's Encrypt — нужен интернет и публичный DNS"
echo " 4) HTTPS свои сертификаты — загрузите fullchain.pem и privkey.pem в папку на сервере"
echo ""
local ssl_choice
if [[ "$INSTALL_INTRANET" -eq 1 ]]; then
read -r -p "Выберите [1-4] [1]: " ssl_choice
ssl_choice="${ssl_choice:-1}"
else
read -r -p "Выберите [1-4] [3]: " ssl_choice
ssl_choice="${ssl_choice:-3}"
fi
case "$ssl_choice" in
1) SSL_TYPE="none" ;;
2) SSL_TYPE="selfsigned" ;;
3) SSL_TYPE="letsencrypt" ;;
4) SSL_TYPE="custom" ;;
*) SSL_TYPE="$default" ;;
esac
}
configure_domains_interactive() {
local existing_api existing_frontend existing_docs existing_ws existing_email
if [[ "$(env_get INSTALL_MODE "")" == "intranet" ]]; then
INSTALL_INTRANET=1
fi
existing_api="$(normalize_domain "$(env_get DOMAIN_API "")")"
existing_frontend="$(normalize_domain "$(env_get DOMAIN_FRONTEND "")")"
existing_docs="$(normalize_domain "$(env_get DOMAIN_DOCS "")")"
existing_ws="$(normalize_domain "$(env_get DOMAIN_WS "")")"
existing_email="$(env_get CERTBOT_EMAIL "")"
local default_api="${CLI_DOMAIN_API:-$existing_api}"
local default_frontend="${CLI_DOMAIN_FRONTEND:-$existing_frontend}"
local default_docs="${CLI_DOMAIN_DOCS:-$existing_docs}"
local default_ws="${CLI_DOMAIN_WS:-$existing_ws}"
local default_minio="${CLI_DOMAIN_MINIO:-$(env_get DOMAIN_MINIO "")}"
local default_minio_console="${CLI_DOMAIN_MINIO_CONSOLE:-$(env_get DOMAIN_MINIO_CONSOLE "")}"
local default_email="${CLI_EMAIL:-$existing_email}"
if [[ "$INSTALL_LOCAL" -eq 1 ]]; then
DOMAIN_API=""
DOMAIN_FRONTEND=""
DOMAIN_DOCS=""
DOMAIN_WS=""
DOMAIN_MINIO=""
DOMAIN_MINIO_CONSOLE=""
derive_public_urls "none"
return
fi
echo ""
echo -e "${BOLD}Настройка доменов${NC}"
if [[ "$INSTALL_INTRANET" -eq 1 ]]; then
echo "Локальная сеть: укажите внутренние DNS-имена (например idpmvk.lpr)."
echo "Пропишите A-записи во внутреннем DNS или файл hosts на клиентах."
echo "Доступ из интернета и Let's Encrypt не требуются."
else
echo "Укажите домен для каждого сервиса (A-запись → IP сервера)."
fi
echo "Enter — оставить текущее значение. localhost без Nginx: ./install.sh --local"
echo ""
if [[ "$INSTALL_INTRANET" -eq 1 ]]; then
if [[ -z "$default_api" ]]; then default_api="api.idpmvk.lpr"; fi
if [[ -z "$default_frontend" ]]; then default_frontend="id.idpmvk.lpr"; fi
if [[ -z "$default_docs" ]]; then default_docs="docs.idpmvk.lpr"; fi
else
if [[ -z "$default_api" ]]; then default_api="api.example.com"; fi
if [[ -z "$default_frontend" ]]; then default_frontend="id.example.com"; fi
if [[ -z "$default_docs" ]]; then default_docs="docs.example.com"; fi
fi
prompt DOMAIN_API "Домен API / Swagger" "$default_api"
prompt DOMAIN_FRONTEND "Домен Frontend (UI)" "$default_frontend"
prompt DOMAIN_DOCS "Домен документации" "$default_docs"
prompt_optional DOMAIN_WS "Домен WebSocket (пусто = ${DOMAIN_API}/ws)" "$default_ws"
prompt_optional DOMAIN_MINIO "Домен MinIO S3 (опционально)" "$default_minio"
prompt_optional DOMAIN_MINIO_CONSOLE "Домен MinIO Console (опционально)" "$default_minio_console"
DOMAIN_API="$(normalize_domain "$DOMAIN_API")"
DOMAIN_FRONTEND="$(normalize_domain "$DOMAIN_FRONTEND")"
DOMAIN_DOCS="$(normalize_domain "$DOMAIN_DOCS")"
DOMAIN_WS="$(normalize_domain "$DOMAIN_WS")"
DOMAIN_MINIO="$(normalize_domain "$DOMAIN_MINIO")"
DOMAIN_MINIO_CONSOLE="$(normalize_domain "$DOMAIN_MINIO_CONSOLE")"
for d in "$DOMAIN_API" "$DOMAIN_FRONTEND" "$DOMAIN_DOCS" "$DOMAIN_WS" "$DOMAIN_MINIO" "$DOMAIN_MINIO_CONSOLE"; do
[[ -z "$d" ]] && continue
validate_domain "$d" || fail "Некорректный домен: $d"
done
[[ -n "$DOMAIN_API" && -n "$DOMAIN_FRONTEND" && -n "$DOMAIN_DOCS" ]] \
|| fail "Домены API, Frontend и Docs обязательны (или используйте --local)"
choose_ssl_type_interactive "$(env_get SSL_TYPE none)"
case "$SSL_TYPE" in
none | selfsigned | custom | letsencrypt) ;;
*) fail "Некорректный SSL_TYPE: $SSL_TYPE (none | selfsigned | custom | letsencrypt)" ;;
esac
if [[ "$SSL_TYPE" == "custom" ]]; then
prompt_custom_cert_settings
fi
if [[ "$SSL_TYPE" == "letsencrypt" ]]; then
if [[ "$INSTALL_INTRANET" -eq 1 && "$NON_INTERACTIVE" -ne 1 ]]; then
warn "Let's Encrypt требует доступ из интернета. Для чистой локальной сети выберите HTTP или selfsigned."
fi
prompt CERTBOT_EMAIL "Email для Let's Encrypt" "${default_email:-admin@${DOMAIN_FRONTEND}}"
[[ -n "$CERTBOT_EMAIL" ]] || fail "Email обязателен для Let's Encrypt"
else
CERTBOT_EMAIL=""
fi
derive_public_urls "$SSL_TYPE"
}
write_env_secrets_if_missing() {
[[ -f .env && "$FORCE_ENV" != "1" ]] && return
local pg_pass rmq_pass minio_pass jwt_access jwt_refresh data_key
pg_pass="$(rand_secret 32)"
rmq_pass="$(rand_secret 32)"
minio_pass="$(rand_secret 24)"
jwt_access="$(rand_secret 64)"
jwt_refresh="$(rand_secret 64)"
data_key="$(rand_secret 48)"
if [[ ! -f .env ]]; then
cp .env.example .env 2>/dev/null || touch .env
fi
load_infra_secrets_into_env
if docker_volume_exists postgres_data; then
if [[ -n "$(env_get POSTGRES_PASSWORD "")" ]]; then
warn "Volume PostgreSQL уже существует — POSTGRES_PASSWORD в .env не меняем"
else
fail "Volume PostgreSQL существует, но POSTGRES_PASSWORD не найден. Восстановите .env или выполните: ./install.sh --reset-db"
fi
pg_pass="$(env_get POSTGRES_PASSWORD "$pg_pass")"
fi
if docker_volume_exists rabbitmq_data; then
warn "Volume RabbitMQ уже существует — RABBITMQ_DEFAULT_PASS не меняем"
rmq_pass="$(env_get RABBITMQ_DEFAULT_PASS "$rmq_pass")"
fi
if docker_volume_exists minio_data; then
warn "Volume MinIO уже существует — MINIO_ROOT_PASSWORD не меняем"
minio_pass="$(env_get MINIO_ROOT_PASSWORD "$minio_pass")"
fi
local need_secrets=0
if [[ "$FORCE_ENV" == "1" ]]; then
need_secrets=1
elif [[ "$(env_get POSTGRES_PASSWORD change-me-postgres)" == "change-me-postgres" ]]; then
need_secrets=1
elif [[ "$(env_get JWT_ACCESS_SECRET change-me-access-secret)" == "change-me-access-secret" ]]; then
need_secrets=1
fi
if [[ "$need_secrets" -eq 1 ]]; then
env_set POSTGRES_PASSWORD "$pg_pass"
env_set RABBITMQ_DEFAULT_PASS "$rmq_pass"
env_set MINIO_ROOT_PASSWORD "$minio_pass"
env_set JWT_ACCESS_SECRET "$jwt_access"
env_set JWT_REFRESH_SECRET "$jwt_refresh"
env_set DATA_ENCRYPTION_KEY "$data_key"
save_infra_secrets
ok "Секреты сгенерированы / синхронизированы"
fi
}
compose_project_name() {
load_env
echo "${COMPOSE_PROJECT_NAME:-lendry-id}"
}
docker_volume_name() {
echo "$(compose_project_name)_$1"
}
docker_volume_exists() {
docker volume inspect "$(docker_volume_name "$1")" >/dev/null 2>&1
}
save_infra_secrets() {
load_env
cat >"$INFRA_SECRETS_FILE" <<EOF
POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-}
RABBITMQ_DEFAULT_PASS=${RABBITMQ_DEFAULT_PASS:-}
MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-}
EOF
chmod 600 "$INFRA_SECRETS_FILE" 2>/dev/null || true
}
load_infra_secrets_into_env() {
[[ -f "$INFRA_SECRETS_FILE" ]] || return 0
# shellcheck disable=SC1091
set -a && source "$INFRA_SECRETS_FILE" && set +a
[[ -n "${POSTGRES_PASSWORD:-}" ]] && env_set POSTGRES_PASSWORD "$POSTGRES_PASSWORD"
[[ -n "${RABBITMQ_DEFAULT_PASS:-}" ]] && env_set RABBITMQ_DEFAULT_PASS "$RABBITMQ_DEFAULT_PASS"
[[ -n "${MINIO_ROOT_PASSWORD:-}" ]] && env_set MINIO_ROOT_PASSWORD "$MINIO_ROOT_PASSWORD"
}
verify_postgres_auth() {
load_env
load_infra_secrets_into_env
[[ -f .env ]] || return 0
docker_cmd compose ps postgres 2>/dev/null | grep -q postgres || return 0
local user pass db
user="$(env_get POSTGRES_USER lendry)"
pass="$(env_get POSTGRES_PASSWORD "")"
db="$(env_get POSTGRES_DB lendry_id)"
[[ -n "$pass" ]] || return 0
if docker_cmd compose exec -T -e PGPASSWORD="$pass" postgres \
psql -U "$user" -d "$db" -c 'SELECT 1' >/dev/null 2>&1; then
ok "PostgreSQL: пароль из .env подходит"
save_infra_secrets
return 0
fi
echo ""
fail "PostgreSQL: пароль в .env не совпадает с данными в volume (ошибка P1000).
Это происходит после FORCE_ENV=1 или смены POSTGRES_PASSWORD при существующей БД.
Решения:
1) Сбросить БД (удалит всех пользователей и данные):
./install.sh --reset-db --yes && ./install.sh --restart
2) Вернуть старый POSTGRES_PASSWORD в .env (если сохранили)
3) Если есть файл ${INFRA_SECRETS_FILE} — скопируйте пароль оттуда в .env"
}
action_reset_db() {
load_env
local vol
vol="$(docker_volume_name postgres_data)"
if [[ "$NON_INTERACTIVE" -ne 1 ]]; then
echo ""
warn "Будет удалён volume ${vol} — все пользователи, сессии и настройки в PostgreSQL пропадут."
read -r -p "Продолжить? [y/N]: " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || fail "Отменено"
fi
log "Остановка сервисов..."
docker_cmd compose --env-file .env --profile proxy down 2>/dev/null || docker_cmd compose --env-file .env down 2>/dev/null || true
log "Удаление ${vol}..."
if docker volume inspect "$vol" >/dev/null 2>&1; then
docker volume rm "$vol" || fail "Не удалось удалить ${vol}"
else
warn "Volume ${vol} не найден — возможно, уже удалён"
fi
ok "PostgreSQL volume удалён. PostgreSQL создастся заново с паролем из .env."
save_infra_secrets
}
# Prisma migrate deploy (fallback db push) — без перезапуска всего стека.
run_prisma_migrations() {
[[ -f .env ]] || fail "Файл .env не найден"
load_env
prepare_compose_stack
build_compose_stack_cmd
if ! docker_cmd "${COMPOSE_STACK_CMD[@]}" ps --status running postgres 2>/dev/null | grep -q postgres; then
warn "PostgreSQL не запущен — поднимаем..."
compose_build_and_up postgres
fi
wait_for_service postgres 90
verify_postgres_auth || true
log "Сборка образа sso-core для миграций..."
docker_cmd "${COMPOSE_STACK_CMD[@]}" build sso-core
local migrate_script
migrate_script='set -e
if npm --workspace @lendry/sso-core run prisma:migrate-deploy; then
echo "[install] Prisma migrate deploy: OK"
else
echo "[install] migrate deploy не выполнен — fallback db push"
npm --workspace @lendry/sso-core run prisma:push
fi'
if docker_cmd inspect --format='{{.State.Running}}' lendry-id-sso-core 2>/dev/null | grep -q true; then
log "Применение миграций через контейнер lendry-id-sso-core..."
docker_cmd exec lendry-id-sso-core sh -c "$migrate_script" || fail "Не удалось применить миграции Prisma"
else
log "Применение миграций через одноразовый контейнер sso-core..."
docker_cmd "${COMPOSE_STACK_CMD[@]}" run --rm --no-deps --entrypoint sh sso-core -c "$migrate_script" \
|| fail "Не удалось применить миграции Prisma"
fi
ok "Миграции Prisma применены"
}
# После обновления кода: миграции БД + пересборка backend/frontend.
action_migrate_rebuild() {
[[ -f .env ]] || fail "Файл .env не найден — сначала выполните ./install.sh --install"
load_env
if [[ "$(env_get INSTALL_MODE local)" != "local" ]] && [[ -n "$(env_get DOMAIN_API "")" ]] && [[ "$NGINX_MODE_CLI" != "host" ]]; then
NGINX_MODE="docker"
env_set NGINX_MODE "docker"
prepare_docker_nginx_ports
fi
prepare_compose_stack
echo ""
echo -e "${BOLD}Миграция БД и пересборка стека${NC}"
echo " (Prisma migrate deploy, sso-core, api-gateway, frontend, docs, media-ws)"
echo ""
log "Шаг 1/4: PostgreSQL и инфраструктура..."
compose_build_and_up postgres redis rabbitmq minio
wait_for_service postgres 90
verify_postgres_auth
log "Шаг 2/4: Миграции Prisma..."
run_prisma_migrations
log "Шаг 3/4: Пересборка backend и frontend..."
compose_build_and_up ldap-auth
wait_for_service ldap-auth 90
compose_build_and_up sso-core
wait_for_service sso-core 120
compose_build_and_up api-gateway
wait_for_service api-gateway 90
wait_for_api_grpc_ready 60 || warn "gRPC api-gateway → sso-core не готов"
compose_build_and_up frontend docs media-ws
wait_for_service frontend 90
wait_for_service docs 60
log "Шаг 4/4: Nginx, прокси и проверка..."
ensure_docker_nginx_configs
ensure_nginx_after_stack_rebuild || warn "Nginx не восстановлен — выполните: ./install.sh --fix-docker-nginx"
post_deploy_proxy_verify 0 || warn "HTTPS-проверка не прошла — выполните: ./install.sh --fix-all"
diagnose_common_issues || true
if compose_uses_proxy_profile && ! docker_nginx_container_running; then
fail "Сайт недоступен: lendry-id-nginx не запущен. Выполните: ./install.sh --fix-all"
fi
if docker_nginx_container_running && system_nginx_is_active; then
warn "Конфликт: системный Nginx и lendry-id-nginx одновременно — выполните: ./install.sh --fix-docker-nginx"
fi
ok "Миграция и пересборка завершены"
echo ""
echo " После git pull используйте этот пункт или: ./install.sh --migrate-rebuild --yes"
echo ""
show_status
}
persist_domain_env() {
write_env_secrets_if_missing
env_set INSTALL_MODE "${INSTALL_MODE:-local}"
env_set NGINX_MODE "${NGINX_MODE:-docker}"
env_set SSL_TYPE "${SSL_TYPE:-none}"
env_set DOMAIN_API "${DOMAIN_API:-}"
env_set DOMAIN_FRONTEND "${DOMAIN_FRONTEND:-}"
env_set DOMAIN_DOCS "${DOMAIN_DOCS:-}"
env_set DOMAIN_WS "${DOMAIN_WS:-}"
env_set DOMAIN_MINIO "${DOMAIN_MINIO:-}"
env_set DOMAIN_MINIO_CONSOLE "${DOMAIN_MINIO_CONSOLE:-}"
env_set PUBLIC_API_URL "${PUBLIC_API_URL}"
env_set PUBLIC_FRONTEND_URL "${PUBLIC_FRONTEND_URL}"
env_set PUBLIC_DOCS_URL "${PUBLIC_DOCS_URL}"
env_set PUBLIC_WS_URL "${PUBLIC_WS_URL}"
env_set USE_NGINX_SSL "${USE_NGINX_SSL:-false}"
env_set CERTBOT_EMAIL "${CERTBOT_EMAIL:-}"
env_set SSL_CERT_DIR "${SSL_CERT_DIR:-$(env_get SSL_CERT_DIR "")}"
env_set SSL_CERT_FULLCHAIN "${SSL_CERT_FULLCHAIN:-$(env_get SSL_CERT_FULLCHAIN "")}"
env_set SSL_CERT_KEY "${SSL_CERT_KEY:-$(env_get SSL_CERT_KEY "")}"
env_set MINIO_PUBLIC_ENDPOINT "${MINIO_PUBLIC_ENDPOINT:-localhost:9000}"
env_set MINIO_USE_SSL "${MINIO_USE_SSL:-false}"
if [[ "$(env_get USE_NGINX_SSL false)" == "true" ]]; then
env_set FEDCM_COOKIE_SECURE "true"
fi
if [[ "$(env_get INSTALL_MODE local)" == "intranet" && -z "$(env_get LDAP_USE_HOST_NETWORK "")" ]]; then
env_set LDAP_USE_HOST_NETWORK "true"
fi
env_set LDAP_USE_HOST_NETWORK "$(env_get LDAP_USE_HOST_NETWORK false)"
env_set LDAP_DNS_SERVERS "$(env_get LDAP_DNS_SERVERS "")"
env_set LDAP_DNS_SEARCH "$(env_get LDAP_DNS_SEARCH "")"
env_set LDAP_EXTRA_HOSTS "$(env_get LDAP_EXTRA_HOSTS "")"
ok "Файл .env обновлён"
derive_ldap_dns_settings
}
derive_ldap_dns_settings() {
load_env
local extra dns first ip
extra="$(env_get LDAP_EXTRA_HOSTS "")"
[[ -n "$extra" ]] || return 0
dns="$(env_get LDAP_DNS_SERVERS "")"
if [[ -z "$dns" ]]; then
first="${extra%%,*}"
ip="${first##*:}"
ip="$(echo "$ip" | xargs)"
if [[ -n "$ip" ]]; then
env_set LDAP_DNS_SERVERS "$ip"
log "LDAP_DNS_SERVERS=${ip} (из LDAP_EXTRA_HOSTS)"
fi
fi
}
sync_ldap_system_hosts() {
load_env
local extra
extra="$(env_get LDAP_EXTRA_HOSTS "")"
[[ -n "$extra" ]] || return 0
log "LDAP: запись имён DC в /etc/hosts..."
local entry host ip
IFS=',' read -ra _entries <<< "$extra"
for entry in "${_entries[@]}"; do
entry="$(echo "$entry" | xargs)"
[[ "$entry" == *:* ]] || continue
host="${entry%%:*}"
host="$(echo "$host" | xargs)"
ip="${entry##*:}"
ip="$(echo "$ip" | xargs)"
[[ -n "$host" && -n "$ip" ]] || continue
if run_root grep -qE "[[:space:]]${host}([[:space:]]|$)" /etc/hosts 2>/dev/null; then
continue
fi
run_root sh -c "printf '%s %s\n' '${ip}' '${host}' >> /etc/hosts" || warn "Не удалось добавить ${host} в /etc/hosts (нужен sudo?)"
done
}
write_compose_override() {
derive_ldap_dns_settings
sync_ldap_system_hosts
resolve_nginx_mode_and_ports
load_env
local mode="${NGINX_MODE:-$(env_get NGINX_MODE host)}"
local has_domains install_mode
has_domains="$(env_get DOMAIN_API "")"
install_mode="$(env_get INSTALL_MODE local)"
local nginx_fragment="" ldap_fragment="" body=""
if [[ -n "$has_domains" && "$install_mode" != "local" && "$mode" == "docker" ]]; then
local http_port https_port
http_port="$(env_get NGINX_HTTP_PORT 80)"
https_port="$(env_get NGINX_HTTPS_PORT 443)"
log "Настройка docker-compose override (Nginx: ${mode})..."
nginx_fragment="$(cat <<EOF
nginx:
ports:
- "${http_port}:80"
- "${https_port}:443"
EOF
)"
fi
ldap_fragment="$(build_ldap_compose_fragment)"
[[ -n "$ldap_fragment" ]] && log "Настройка docker-compose override (LDAP DNS / host network)..."
body="${nginx_fragment}${ldap_fragment}"
if [[ -z "$body" ]]; then
[[ -f "$COMPOSE_OVERRIDE" ]] && rm -f "$COMPOSE_OVERRIDE"
return
fi
cat >"$COMPOSE_OVERRIDE" <<EOF
services:
${body}
EOF
}
build_ldap_compose_fragment() {
load_env
local use_host dns_servers dns_search extra_hosts
use_host="$(env_get LDAP_USE_HOST_NETWORK false)"
dns_servers="$(env_get LDAP_DNS_SERVERS "")"
dns_search="$(env_get LDAP_DNS_SEARCH "")"
extra_hosts="$(env_get LDAP_EXTRA_HOSTS "")"
[[ "$use_host" == "true" || -n "$dns_servers" || -n "$extra_hosts" ]] || return 0
if [[ "$use_host" == "true" ]]; then
cat <<'EOF'
ldap-auth:
network_mode: host
sso-core:
environment:
LDAP_AUTH_URL: http://host.docker.internal:8086
extra_hosts:
- "host.docker.internal:host-gateway"
EOF
return 0
fi
local fragment=$'\n ldap-auth:'
if [[ -n "$dns_servers" ]]; then
fragment+=$'\n dns:'
local server
IFS=',' read -ra _dns <<< "$dns_servers"
for server in "${_dns[@]}"; do
server="$(echo "$server" | xargs)"
[[ -n "$server" ]] && fragment+=$'\n - "'"${server}"'"'
done
fi
if [[ -n "$dns_search" ]]; then
fragment+=$'\n dns_search:'
local zone
IFS=',' read -ra _zones <<< "$dns_search"
for zone in "${_zones[@]}"; do
zone="$(echo "$zone" | xargs)"
[[ -n "$zone" ]] && fragment+=$'\n - "'"${zone}"'"'
done
fi
if [[ -n "$extra_hosts" ]]; then
fragment+=$'\n extra_hosts:'
local host_entry
IFS=',' read -ra _hosts <<< "$extra_hosts"
for host_entry in "${_hosts[@]}"; do
host_entry="$(echo "$host_entry" | xargs)"
[[ -n "$host_entry" ]] && fragment+=$'\n - "'"${host_entry}"'"'
done
fi
printf '%s' "$fragment"
}
HOST_EDGE_SERVICES=(api-gateway frontend docs media-ws)
recreate_host_port_services() {
load_env
[[ "$(env_get NGINX_MODE docker)" == "host" ]] || return 0
[[ -n "$(env_get DOMAIN_API "")" ]] || return 0
[[ "$(env_get INSTALL_MODE local)" == "local" ]] && return 0
log "Пересоздание сервисов с пробросом портов на 127.0.0.1..."
build_compose_stack_cmd
docker_cmd "${COMPOSE_STACK_CMD[@]}" up -d --force-recreate "${HOST_EDGE_SERVICES[@]}"
}
docker_publishes_port() {
local container="$1"
local host_port="$2"
docker_cmd port "$container" 2>/dev/null | grep -q "127.0.0.1:${host_port}" || \
docker_cmd port "$container" 2>/dev/null | grep -q ":${host_port}->"
}
ensure_host_upstreams() {
load_env
[[ "$(env_get NGINX_MODE docker)" == "host" ]] || return 0
[[ -n "$(env_get DOMAIN_API "")" ]] || return 0
[[ "$(env_get INSTALL_MODE local)" == "local" ]] && return 0
local missing=0
for spec in "3000:API" "3002:Frontend" "3003:Docs"; do
local port="${spec%%:*}"
if ! port_in_use "$port"; then
warn "Порт ${port} (${spec#*:}) не слушается на хосте"
missing=1
fi
done
if [[ "$missing" -eq 1 ]]; then
warn "Проброс портов не применён — пересоздаём edge-сервисы (NGINX_MODE=host)"
recreate_host_port_services
local attempt=0
while [[ $attempt -lt 12 ]]; do
missing=0
for spec in "3000:API" "3002:Frontend" "3003:Docs"; do
local port="${spec%%:*}"
if ! port_in_use "$port"; then
missing=1
fi
done
[[ "$missing" -eq 0 ]] && break
attempt=$((attempt + 1))
sleep 5
done
for spec in "3000:API" "3002:Frontend" "3003:Docs"; do
local port="${spec%%:*}"
if ! port_in_use "$port"; then
echo ""
warn "Диагностика:"
docker_cmd ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' 2>/dev/null | sed 's/^/ /' || true
docker_cmd port lendry-id-frontend 2>/dev/null | sed 's/^/ frontend: /' || echo " frontend: порты не опубликованы"
echo ""
fail "Порт ${port} недоступен. Проверьте: grep NGINX_MODE .env && cat docker-compose.override.yml && docker compose logs frontend"
fi
done
ok "Порты 3000/3002/3003 доступны на localhost"
fi
}
nginx_upstream() {
case "$1" in
api)
if [[ "$NGINX_MODE" == "docker" ]]; then
echo "http://api-gateway:3000"
else
echo "http://127.0.0.1:3000"
fi
;;
frontend)
[[ "$NGINX_MODE" == "docker" ]] && echo "http://frontend:3000" || echo "http://127.0.0.1:3002"
;;
docs)
[[ "$NGINX_MODE" == "docker" ]] && echo "http://docs:3000" || echo "http://127.0.0.1:3003"
;;
ws)
[[ "$NGINX_MODE" == "docker" ]] && echo "http://media-ws:8085" || echo "http://127.0.0.1:8085"
;;
minio)
[[ "$NGINX_MODE" == "docker" ]] && echo "http://minio:9000" || echo "http://127.0.0.1:9000"
;;
minio-console)
[[ "$NGINX_MODE" == "docker" ]] && echo "http://minio:9001" || echo "http://127.0.0.1:9001"
;;
esac
}
nginx_cert_base_path() {
local domain="$1"
local ssl_type="$2"
if [[ "$ssl_type" == "letsencrypt" ]]; then
echo "/etc/letsencrypt/live/${domain}"
elif [[ "$ssl_type" == "selfsigned" ]]; then
if [[ "$NGINX_MODE" == "docker" ]]; then
echo "/etc/nginx/certs/shared-intranet"
else
echo "${LOCAL_CERTS_DIR}/shared-intranet"
fi
elif [[ "$ssl_type" == "custom" ]]; then
if [[ "$NGINX_MODE" == "docker" ]]; then
echo "/etc/nginx/certs/custom"
else
echo "${LOCAL_CERTS_DIR}/custom"
fi
elif [[ "$NGINX_MODE" == "docker" ]]; then
echo "/etc/nginx/certs/${domain}"
else
echo "${LOCAL_CERTS_DIR}/${domain}"
fi
}
intranet_wildcard_base_domain() {
local domain="${1:-}"
[[ -z "$domain" ]] && return 0
if [[ "$domain" == *.*.* ]]; then
echo "${domain#*.}"
else
echo "$domain"
fi
}
# Регистрируемый домен (eTLD+1) — нужен для FedCM well-known.
# FedCM ВСЕГДА запрашивает /.well-known/web-identity с eTLD+1 домена IdP:
# sso.idpmvk.lpr -> idpmvk.lpr
# api.id.example.ru -> example.ru
# Берём два последних лейбла (корректно для большинства зон и кастомных TLD .lpr).
# Исключение — многосоставные суффиксы вида .co.uk; для них укажите домен вручную.
registrable_domain() {
local domain="${1:-}"
[[ -z "$domain" ]] && return 0
local -a labels
IFS='.' read -ra labels <<< "$domain"
local n=${#labels[@]}
if (( n <= 2 )); then
echo "$domain"
else
echo "${labels[n-2]}.${labels[n-1]}"
fi
}
collect_oauth_redirect_sans_hosts() {
local f="${LOCAL_CERTS_DIR}/oauth-redirect-sans.txt"
[[ -f "$f" ]] || return 0
local host
while IFS= read -r host || [[ -n "$host" ]]; do
host="${host#"${host%%[![:space:]]*}"}"
host="${host%"${host##*[![:space:]]}"}"
[[ -z "$host" || "$host" == \#* ]] && continue
echo "$host"
done < "$f"
}
# Нормализованный список SAN для сравнения сертификата с желаемым набором.
self_signed_sans_fingerprint() {
collect_self_signed_sans | tr ',' '\n' | sed 's/^DNS://' | sort -u | paste -sd, -
}
current_self_signed_sans_fingerprint() {
local cert="${LOCAL_CERTS_DIR}/shared-intranet/fullchain.pem"
[[ -f "$cert" ]] || return 1
openssl x509 -in "$cert" -noout -ext subjectAltName 2>/dev/null \
| sed 's/ *subjectAltName=//;s/DNS://g' \
| tr ',' '\n' | sed 's/^ *//' | sort -u | paste -sd, -
}
renew_self_signed_cert_if_sans_changed() {
[[ "$(env_get SSL_TYPE none)" != "selfsigned" ]] && return 0
local desired current
desired="$(self_signed_sans_fingerprint)"
current="$(current_self_signed_sans_fingerprint 2>/dev/null || true)"
if [[ -z "$current" || "$desired" != "$current" ]]; then
if [[ -n "$current" ]]; then
log "SAN сертификата изменились (OAuth redirect URI) — пересоздаём self-signed"
fi
generate_all_self_signed_certs --force
return 0
fi
}
collect_self_signed_sans() {
local -a sans=()
local base domain
load_env
for domain in "$DOMAIN_API" "$DOMAIN_FRONTEND" "$DOMAIN_DOCS" "${DOMAIN_WS:-}" "${DOMAIN_MINIO:-}" "${DOMAIN_MINIO_CONSOLE:-}"; do
[[ -z "$domain" ]] && continue
sans+=("DNS:${domain}")
done
base="$(intranet_wildcard_base_domain "${DOMAIN_FRONTEND:-$DOMAIN_API}")"
if [[ -n "$base" ]]; then
sans+=("DNS:*.${base}")
sans+=("DNS:${base}")
fi
# eTLD+1 для FedCM well-known (например idpmvk.lpr) — гарантированно в сертификате.
local apex
apex="$(registrable_domain "${DOMAIN_FRONTEND:-$DOMAIN_API}")"
if [[ -n "$apex" && "$apex" != "$base" ]]; then
sans+=("DNS:${apex}")
fi
# Авто: домены из redirect_uri OAuth-приложений (nginx/certs/oauth-redirect-sans.txt).
local oauth_host
while IFS= read -r oauth_host; do
[[ -z "$oauth_host" ]] && continue
sans+=("DNS:${oauth_host}")
done < <(collect_oauth_redirect_sans_hosts)
# Ручной override (дополнительно к OAuth), через запятую в .env.
local extra_sans="${EXTRA_SSL_SANS:-}"
if [[ -n "$extra_sans" ]]; then
local entry
IFS=',' read -ra _extra_sans <<< "$extra_sans"
for entry in "${_extra_sans[@]}"; do
entry="${entry#"${entry%%[![:space:]]*}"}"
entry="${entry%"${entry##*[![:space:]]}"}"
[[ -z "$entry" ]] && continue
sans+=("DNS:${entry}")
done
fi
# Уникальные SAN
local -A _seen=()
local -a unique=()
local item key
for item in "${sans[@]}"; do
key="${item#DNS:}"
[[ -n "${_seen[$key]:-}" ]] && continue
_seen[$key]=1
unique+=("$item")
done
(IFS=,; echo "${unique[*]}")
}
find_custom_cert_file() {
local dir="$1"
local preferred="${2:-}"
shift 2 || true
local -a fallbacks=("$@")
local name
if [[ -n "$preferred" ]]; then
[[ -f "${dir}/${preferred}" ]] || fail "Файл не найден: ${dir}/${preferred}"
echo "${dir}/${preferred}"
return 0
fi
for name in "${fallbacks[@]}"; do
if [[ -f "${dir}/${name}" ]]; then
echo "${dir}/${name}"
return 0
fi
done
return 1
}
find_custom_cert_file_auto() {
local dir="$1"
local kind="$2"
local f base
shopt -s nullglob
if [[ "$kind" == "key" ]]; then
for f in "$dir"/*-key.pem "$dir"/*_key.pem "$dir"/*.key; do
if [[ -f "$f" ]]; then
shopt -u nullglob
echo "$f"
return 0
fi
done
shopt -u nullglob
return 1
fi
for f in "$dir"/_wildcard*.pem "$dir"/*wildcard*.pem "$dir"/*.pem "$dir"/*.crt; do
[[ -f "$f" ]] || continue
base="$(basename "$f")"
[[ "$base" == *-key.pem || "$base" == *_key.pem ]] && continue
shopt -u nullglob
echo "$f"
return 0
done
shopt -u nullglob
return 1
}
resolve_custom_cert_source_dir() {
if [[ -n "$CLI_CERT_DIR" ]]; then
SSL_CERT_DIR="$CLI_CERT_DIR"
elif [[ -n "${SSL_CERT_DIR:-}" ]]; then
:
elif [[ -n "$(env_get SSL_CERT_DIR "")" ]]; then
SSL_CERT_DIR="$(env_get SSL_CERT_DIR "")"
elif [[ -d "$DEFAULT_CERT_UPLOAD_DIR" ]]; then
SSL_CERT_DIR="$DEFAULT_CERT_UPLOAD_DIR"
fi
}
prompt_custom_cert_settings() {
resolve_custom_cert_source_dir
local default_dir="${SSL_CERT_DIR:-$DEFAULT_CERT_UPLOAD_DIR}"
local default_fullchain="${CLI_CERT_FULLCHAIN:-$(env_get SSL_CERT_FULLCHAIN "")}"
local default_key="${CLI_CERT_KEY:-$(env_get SSL_CERT_KEY "")}"
if [[ "$NON_INTERACTIVE" -eq 1 ]]; then
SSL_CERT_DIR="${SSL_CERT_DIR:-$default_dir}"
SSL_CERT_FULLCHAIN="${SSL_CERT_FULLCHAIN:-$default_fullchain}"
SSL_CERT_KEY="${SSL_CERT_KEY:-$default_key}"
[[ -d "$SSL_CERT_DIR" ]] || fail "Папка с сертификатами не найдена: $SSL_CERT_DIR (укажите --cert-dir PATH)"
return 0
fi
echo ""
echo -e "${BOLD}Свои SSL-сертификаты:${NC}"
echo " Положите на сервер файлы сертификата и ключа, затем укажите папку."
echo " Поддерживаются имена: fullchain.pem, cert.pem, _wildcard.domain.pem"
echo " и ключи: privkey.pem, key.pem, _wildcard.domain-key.pem"
echo " Рекомендуемая папка: ${DEFAULT_CERT_UPLOAD_DIR}/"
echo ""
prompt SSL_CERT_DIR "Папка с сертификатами на сервере" "$default_dir"
[[ -d "$SSL_CERT_DIR" ]] || fail "Папка не найдена: $SSL_CERT_DIR"
prompt_optional SSL_CERT_FULLCHAIN "Имя файла сертификата (полная цепочка, пусто = авто)" "$default_fullchain"
prompt_optional SSL_CERT_KEY "Имя файла приватного ключа (пусто = авто)" "$default_key"
}
validate_custom_certificate_pair() {
local fullchain="$1"
local key="$2"
[[ -f "$fullchain" ]] || fail "Файл сертификата не найден: $fullchain"
[[ -f "$key" ]] || fail "Файл ключа не найден: $key"
[[ -r "$fullchain" ]] || fail "Нет прав на чтение сертификата: $fullchain"
[[ -r "$key" ]] || fail "Нет прав на чтение ключа: $key"
has_cmd openssl || return 0
openssl x509 -in "$fullchain" -noout >/dev/null 2>&1 \
|| fail "Некорректный файл сертификата: $fullchain"
if openssl rsa -in "$key" -check -noout >/dev/null 2>&1 \
|| openssl ec -in "$key" -check -noout >/dev/null 2>&1 \
|| openssl pkey -in "$key" -noout >/dev/null 2>&1; then
:
else
fail "Некорректный файл приватного ключа: $key"
fi
if ! openssl x509 -noout -modulus -in "$fullchain" 2>/dev/null \
| cmp -s <(openssl rsa -noout -modulus -in "$key" 2>/dev/null) - 2>/dev/null \
&& ! openssl x509 -noout -modulus -in "$fullchain" 2>/dev/null \
| cmp -s <(openssl ec -noout -modulus -in "$key" 2>/dev/null) - 2>/dev/null \
&& ! openssl x509 -noout -pubkey -in "$fullchain" 2>/dev/null \
| cmp -s <(openssl pkey -pubout -in "$key" 2>/dev/null) - 2>/dev/null; then
warn "Не удалось проверить соответствие сертификата и ключа — продолжаем установку"
fi
}
install_custom_certificates() {
local force="${1:-}"
load_env
resolve_custom_cert_source_dir
SSL_CERT_DIR="${SSL_CERT_DIR:-$(env_get SSL_CERT_DIR "")}"
SSL_CERT_FULLCHAIN="${SSL_CERT_FULLCHAIN:-$(env_get SSL_CERT_FULLCHAIN "")}"
SSL_CERT_KEY="${SSL_CERT_KEY:-$(env_get SSL_CERT_KEY "")}"
[[ -n "$SSL_CERT_DIR" ]] || fail "Не задана папка с сертификатами (SSL_CERT_DIR или --cert-dir)"
[[ -d "$SSL_CERT_DIR" ]] || fail "Папка с сертификатами не найдена: $SSL_CERT_DIR"
local src_fullchain src_key
src_fullchain="$(find_custom_cert_file "$SSL_CERT_DIR" "$SSL_CERT_FULLCHAIN" \
fullchain.pem cert.pem certificate.pem server.crt)" \
|| src_fullchain="$(find_custom_cert_file_auto "$SSL_CERT_DIR" cert)" \
|| fail "Не найден файл сертификата в ${SSL_CERT_DIR}. Положите fullchain.pem, _wildcard.domain.pem и т.п., либо укажите --cert-fullchain"
src_key="$(find_custom_cert_file "$SSL_CERT_DIR" "$SSL_CERT_KEY" \
privkey.pem key.pem server.key)" \
|| src_key="$(find_custom_cert_file_auto "$SSL_CERT_DIR" key)" \
|| fail "Не найден файл ключа в ${SSL_CERT_DIR}. Положите privkey.pem, domain-key.pem и т.п., либо укажите --cert-key"
validate_custom_certificate_pair "$src_fullchain" "$src_key"
if [[ "$force" != "--force" \
&& -f "${CUSTOM_CERT_TARGET_DIR}/fullchain.pem" \
&& -f "${CUSTOM_CERT_TARGET_DIR}/privkey.pem" ]] \
&& cmp -s "$src_fullchain" "${CUSTOM_CERT_TARGET_DIR}/fullchain.pem" \
&& cmp -s "$src_key" "${CUSTOM_CERT_TARGET_DIR}/privkey.pem"; then
log "Сертификаты уже установлены: ${CUSTOM_CERT_TARGET_DIR}/"
return 0
fi
log "Установка своих сертификатов из ${SSL_CERT_DIR}..."
mkdir -p "$CUSTOM_CERT_TARGET_DIR"
cp "$src_fullchain" "${CUSTOM_CERT_TARGET_DIR}/fullchain.pem"
cp "$src_key" "${CUSTOM_CERT_TARGET_DIR}/privkey.pem"
chmod 644 "${CUSTOM_CERT_TARGET_DIR}/fullchain.pem" 2>/dev/null || true
chmod 600 "${CUSTOM_CERT_TARGET_DIR}/privkey.pem" 2>/dev/null || true
env_set SSL_CERT_DIR "$SSL_CERT_DIR"
env_set SSL_CERT_FULLCHAIN "$SSL_CERT_FULLCHAIN"
env_set SSL_CERT_KEY "$SSL_CERT_KEY"
ok "Сертификаты установлены: ${CUSTOM_CERT_TARGET_DIR}/"
if has_cmd openssl; then
log "Срок действия: $(openssl x509 -in "${CUSTOM_CERT_TARGET_DIR}/fullchain.pem" -noout -enddate 2>/dev/null | cut -d= -f2-)"
log "Домены (SAN): $(openssl x509 -in "${CUSTOM_CERT_TARGET_DIR}/fullchain.pem" -noout -ext subjectAltName 2>/dev/null | sed 's/ *//g' || echo "не указаны")"
fi
}
generate_shared_self_signed_cert() {
local force="${1:-}"
local cert_dir="${LOCAL_CERTS_DIR}/shared-intranet"
local base sans cn
mkdir -p "$cert_dir"
base="$(intranet_wildcard_base_domain "${DOMAIN_FRONTEND:-$DOMAIN_API}")"
[[ -z "$base" ]] && base="${DOMAIN_FRONTEND:-$DOMAIN_API}"
cn="*.${base}"
sans="$(collect_self_signed_sans)"
if [[ -f "${cert_dir}/fullchain.pem" && -f "${cert_dir}/privkey.pem" && "$force" != "--force" ]]; then
return 0
fi
log "Общий самоподписанный сертификат: ${cn} (${sans})"
if has_cmd openssl; then
openssl req -x509 -nodes -days 825 -newkey rsa:2048 \
-keyout "${cert_dir}/privkey.pem" \
-out "${cert_dir}/fullchain.pem" \
-subj "/CN=${cn}/O=Lendry ID Intranet/C=RU" \
-addext "subjectAltName=${sans}" 2>/dev/null || \
openssl req -x509 -nodes -days 825 -newkey rsa:2048 \
-keyout "${cert_dir}/privkey.pem" \
-out "${cert_dir}/fullchain.pem" \
-subj "/CN=${cn}/O=Lendry ID Intranet/C=RU"
chmod 600 "${cert_dir}/privkey.pem" 2>/dev/null || true
else
fail "openssl не найден — нужен для самоподписанных сертификатов"
fi
}
generate_all_self_signed_certs() {
local force="${1:-}"
load_env
if [[ "$force" == "--force" ]]; then
rm -f "${LOCAL_CERTS_DIR}/shared-intranet/fullchain.pem" "${LOCAL_CERTS_DIR}/shared-intranet/privkey.pem"
fi
generate_shared_self_signed_cert "$force"
ok "Самоподписанный сертификат создан: ${LOCAL_CERTS_DIR}/shared-intranet"
}
# Лимит тела запроса в nginx (APK до 350 МБ + запас на multipart).
NGINX_RELEASE_UPLOAD_BODY_SIZE="${NGINX_RELEASE_UPLOAD_BODY_SIZE:-512m}"
nginx_conf_target() {
local label="$1"
if [[ "$NGINX_MODE" == "docker" ]]; then
echo "${LOCAL_CONF_DIR}/${NGINX_PREFIX}-${label}.conf"
else
echo "${NGINX_AVAILABLE}/${NGINX_PREFIX}-${label}.conf"
fi
}
nginx_publish_config() {
local label="$1"
local conf
conf="$(nginx_conf_target "$label")"
if [[ "$NGINX_MODE" == "host" ]]; then
run_root ln -sf "$conf" "${NGINX_ENABLED}/${NGINX_PREFIX}-${label}.conf"
fi
}
nginx_proxy_headers=" proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header Authorization \$http_authorization;
proxy_read_timeout 300s;
proxy_connect_timeout 5s;"
# API upstream: keepalive вместо Connection: close — иначе лавина параллельных запросов SPA даёт 502.
nginx_proxy_headers_api=" proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header Authorization \$http_authorization;
proxy_http_version 1.1;
proxy_set_header Connection \"\";
proxy_read_timeout 900s;
proxy_send_timeout 900s;
proxy_connect_timeout 5s;
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 3;"
# proxy_pass к корню апстрима БЕЗ $request_uri — иначе nginx считает директиву
# «variable proxy_pass» и требует resolver (502: no resolver defined to resolve lendry-id-frontend).
# Без URI в proxy_pass полный путь клиента передаётся upstream как есть.
render_proxy_root() {
local key="$1" up var
up="$(nginx_upstream "$key")"
if [[ "$NGINX_MODE" == "docker" ]]; then
var="idp_up_${key//-/_}"
printf ' set $%s %s;\n proxy_pass $%s;' "$var" "$up" "$var"
return 0
fi
printf ' proxy_pass %s;' "$up"
}
# proxy_pass с заменой пути на фиксированный ($2, например /ws или /oauth/).
render_proxy_rewrite() {
local key="$1" target="$2" up var
up="$(nginx_upstream "$key")"
if [[ "$NGINX_MODE" == "docker" ]]; then
var="idp_up_${key//-/_}"
printf ' set $%s %s;\n rewrite ^.*$ %s break;\n proxy_pass $%s;' "$var" "$up" "$target" "$var"
return 0
fi
printf ' rewrite ^.*$ %s break;\n proxy_pass %s;' "$target" "$up"
}
# proxy_pass для /idp-api/ — в Docker через variable proxy_pass, чтобы nginx
# не держал stale IP api-gateway после recreate и резолвил через 127.0.0.11.
render_proxy_idp_api() {
local up
up="$(nginx_upstream api)"
if [[ "$NGINX_MODE" == "docker" ]]; then
printf ' set $idp_up_api %s;\n rewrite ^/idp-api/?(.*)$ /$1 break;\n proxy_pass $idp_up_api;' "$up"
return 0
fi
printf ' proxy_pass %s/;' "$up"
}
# WebSocket /idp-api/ws → media-ws /ws.
render_proxy_idp_ws() {
local up
up="$(nginx_upstream ws)"
if [[ "$NGINX_MODE" == "docker" ]]; then
printf ' set $idp_up_ws %s;\n rewrite ^/idp-api/ws$ /ws break;\n rewrite ^/idp-api/ws/(.*)$ /ws/$1 break;\n proxy_pass $idp_up_ws;' "$up"
return 0
fi
printf ' proxy_pass %s/ws;' "$up"
}
# Старые конфиги с $request_uri дают 502 «no resolver defined» на location /.
patch_legacy_nginx_proxy_pass() {
[[ "$NGINX_MODE" == "docker" ]] || return 0
local conf patched=0
shopt -s nullglob
for conf in "${LOCAL_CONF_DIR}/${NGINX_PREFIX}-"*.conf; do
[[ -f "$conf" ]] || continue
if grep -q 'proxy_pass.*\$request_uri' "$conf" 2>/dev/null; then
log "Патч legacy proxy_pass \$request_uri → $(basename "$conf")"
sed -i -E 's|proxy_pass (http://[^;]+)\$request_uri;|proxy_pass \1;|g' "$conf"
patched=1
fi
done
shopt -u nullglob
if [[ $patched -eq 1 ]]; then
ok "Legacy proxy_pass \$request_uri исправлен в nginx/conf.d"
reload_docker_nginx || true
fi
}
nginx_write_conf() {
local conf="$1"
if [[ "$NGINX_MODE" == "docker" ]]; then
cat >"$conf"
else
run_root tee "$conf" >/dev/null
fi
}
nginx_ssl_directives() {
local domain="$1"
local ssl_type="$2"
local cert_base
cert_base="$(nginx_cert_base_path "$domain" "$ssl_type")"
if [[ "$ssl_type" == "letsencrypt" ]]; then
cat <<EOF
ssl_certificate ${cert_base}/fullchain.pem;
ssl_certificate_key ${cert_base}/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
EOF
else
cat <<EOF
ssl_certificate ${cert_base}/fullchain.pem;
ssl_certificate_key ${cert_base}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
EOF
fi
}
nginx_http_server_preamble() {
local domain="$1"
local ssl_type="$2"
if [[ "$ssl_type" == "letsencrypt" ]]; then
cat <<EOF
server {
listen 80;
listen [::]:80;
server_name ${domain};
location /.well-known/acme-challenge/ {
root ${CERTBOT_WEBROOT};
}
location / {
return 301 https://\$host\$request_uri;
}
}
EOF
elif [[ "$ssl_type" == "selfsigned" || "$ssl_type" == "custom" ]]; then
cat <<EOF
server {
listen 80;
listen [::]:80;
server_name ${domain};
location / {
return 301 https://\$host\$request_uri;
}
}
EOF
fi
}
build_ws_block() {
local ws_on_api="$1"
[[ "$ws_on_api" != "true" ]] && return 0
local proxy_ws
proxy_ws="$(render_proxy_rewrite ws /ws)"
cat <<EOF
location /ws {
${proxy_ws}
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_read_timeout 86400s;
}
EOF
}
# Блок location / для отдельного домена API (api.example.com).
# CORS полностью на api-gateway (enableCors) — nginx только проксирует,
# иначе add_header/if в nginx ломают ответы на авторизованные cross-origin запросы (502).
# docker: НЕ используем upstream{} — nginx резолвит api-gateway один раз при старте
# и после пересборки контейнера (новый IP) все запросы дают 502 Connection refused.
build_nginx_api_upstream_block() {
if [[ "$NGINX_MODE" == "docker" ]]; then
return 0
fi
cat <<EOF
upstream ${NGINX_PREFIX}_api {
server 127.0.0.1:3000;
keepalive 64;
}
EOF
}
build_nginx_api_location_block() {
if [[ "$NGINX_MODE" == "docker" ]]; then
local proxy proxy_idp proxy_ws proxy_frontend
proxy="$(render_proxy_root api)"
proxy_idp="$(render_proxy_idp_api)"
proxy_ws="$(render_proxy_idp_ws)"
proxy_frontend="$(render_proxy_root frontend)"
cat <<EOF
# FedCM login_url same-origin: форма входа на API-домене проксируется на frontend.
location /auth/ {
${proxy_frontend}
proxy_http_version 1.1;
${nginx_proxy_headers}
}
location /_next/ {
${proxy_frontend}
proxy_http_version 1.1;
${nginx_proxy_headers}
}
location = /icon.svg {
${proxy_frontend}
proxy_http_version 1.1;
${nginx_proxy_headers}
}
location = /favicon.ico {
${proxy_frontend}
proxy_http_version 1.1;
${nginx_proxy_headers}
}
# Алиас /idp-api на API-домене: совместимость с PUBLIC_API_URL из документации и curl.
location ^~ /idp-api/ws {
${proxy_ws}
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_read_timeout 86400s;
proxy_connect_timeout 5s;
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 3;
}
location ^~ /idp-api/ {
client_max_body_size ${NGINX_RELEASE_UPLOAD_BODY_SIZE};
client_body_timeout 900s;
${proxy_idp}
${nginx_proxy_headers_api}
}
location / {
${proxy}
${nginx_proxy_headers_api}
}
EOF
return 0
fi
cat <<EOF
location / {
proxy_pass http://${NGINX_PREFIX}_api;
proxy_http_version 1.1;
proxy_set_header Connection "";
${nginx_proxy_headers}
}
EOF
}
write_nginx_site_api() {
local domain="$1"
local ssl_type="${2:-none}"
local ws_on_api="${3:-true}"
local conf ws_block http_preamble ssl_block api_upstream api_location server_dns
conf="$(nginx_conf_target api)"
ws_block="$(build_ws_block "$ws_on_api")"
http_preamble="$(nginx_http_server_preamble "$domain" "$ssl_type")"
api_upstream="$(build_nginx_api_upstream_block)"
api_location="$(build_nginx_api_location_block)"
server_dns="$(nginx_docker_server_dns_block)"
if [[ "$ssl_type" == "none" ]]; then
nginx_write_conf "$conf" <<EOF
${api_upstream}
server {
listen 80;
listen [::]:80;
server_name ${domain};
client_max_body_size ${NGINX_RELEASE_UPLOAD_BODY_SIZE};
${server_dns}
${ws_block}
${api_location}
}
EOF
else
ssl_block="$(nginx_ssl_directives "$domain" "$ssl_type")"
nginx_write_conf "$conf" <<EOF
${api_upstream}
${http_preamble}server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name ${domain};
${ssl_block}
client_max_body_size ${NGINX_RELEASE_UPLOAD_BODY_SIZE};
${server_dns}
${ws_block}
${api_location}
}
EOF
fi
nginx_publish_config "api"
}
build_nginx_idp_api_proxy_block() {
local proxy_ws proxy_idp proxy_oauth proxy_wk proxy_fedcm
proxy_ws="$(render_proxy_idp_ws)"
proxy_idp="$(render_proxy_idp_api)"
proxy_oauth="$(render_proxy_root api)"
proxy_wk="$(render_proxy_root api)"
proxy_fedcm="$(render_proxy_root api)"
cat <<EOF
# ^~ обязателен: иначе regex ниже перехватывает /idp-api/ws и шлёт WS на api-gateway вместо media-ws
location ^~ /idp-api/ws {
${proxy_ws}
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_read_timeout 86400s;
proxy_connect_timeout 5s;
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 3;
}
location ^~ /idp-api/ {
client_max_body_size ${NGINX_RELEASE_UPLOAD_BODY_SIZE};
client_body_timeout 900s;
${proxy_idp}
${nginx_proxy_headers_api}
}
location /oauth/ {
${proxy_oauth}
${nginx_proxy_headers_api}
}
location /.well-known/ {
${proxy_wk}
${nginx_proxy_headers_api}
}
location /fedcm/ {
${proxy_fedcm}
${nginx_proxy_headers_api}
}
EOF
}
nginx_docker_server_dns_block() {
if [[ "$NGINX_MODE" != "docker" ]]; then
return 0
fi
cat <<'EOF'
# Docker embedded DNS — страховка для variable proxy_pass и после recreate контейнеров.
resolver 127.0.0.11 valid=10s ipv6=off;
EOF
}
write_nginx_site_frontend() {
local domain="$1"
local ssl_type="${2:-none}"
local conf http_preamble ssl_block idp_api_block proxy_root server_dns
conf="$(nginx_conf_target frontend)"
proxy_root="$(render_proxy_root frontend)"
http_preamble="$(nginx_http_server_preamble "$domain" "$ssl_type")"
idp_api_block="$(build_nginx_idp_api_proxy_block)"
server_dns="$(nginx_docker_server_dns_block)"
if [[ "$ssl_type" == "none" ]]; then
nginx_write_conf "$conf" <<EOF
server {
listen 80;
listen [::]:80;
server_name ${domain};
client_max_body_size ${NGINX_RELEASE_UPLOAD_BODY_SIZE};
${server_dns}
${idp_api_block}
location / {
${proxy_root}
proxy_http_version 1.1;
${nginx_proxy_headers}
}
}
EOF
else
ssl_block="$(nginx_ssl_directives "$domain" "$ssl_type")"
nginx_write_conf "$conf" <<EOF
${http_preamble}server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name ${domain};
${ssl_block}
client_max_body_size ${NGINX_RELEASE_UPLOAD_BODY_SIZE};
${server_dns}
${idp_api_block}
location / {
${proxy_root}
proxy_http_version 1.1;
${nginx_proxy_headers}
}
}
EOF
fi
nginx_publish_config "frontend"
}
# Сервер для eTLD+1 (например idpmvk.lpr): обслуживает FedCM well-known.
# Браузер при FedCM-логине из стороннего приложения запрашивает
# https://<eTLD+1>/.well-known/web-identity. Если этого домена нет в DNS —
# получаем ERR_NAME_NOT_RESOLVED. Зеркалим idp-api/fedcm/oauth/.well-known на
# api-gateway и корень на фронтенд, чтобы домен был полноценным алиасом IdP.
write_nginx_site_fedcm_apex() {
local domain="$1"
local ssl_type="${2:-none}"
local conf http_preamble ssl_block idp_api_block proxy_root server_dns
conf="$(nginx_conf_target fedcm-apex)"
proxy_root="$(render_proxy_root frontend)"
http_preamble="$(nginx_http_server_preamble "$domain" "$ssl_type")"
idp_api_block="$(build_nginx_idp_api_proxy_block)"
server_dns="$(nginx_docker_server_dns_block)"
if [[ "$ssl_type" == "none" ]]; then
nginx_write_conf "$conf" <<EOF
server {
listen 80;
listen [::]:80;
server_name ${domain};
client_max_body_size ${NGINX_RELEASE_UPLOAD_BODY_SIZE};
${server_dns}
${idp_api_block}
location / {
${proxy_root}
proxy_http_version 1.1;
${nginx_proxy_headers}
}
}
EOF
else
ssl_block="$(nginx_ssl_directives "$domain" "$ssl_type")"
nginx_write_conf "$conf" <<EOF
${http_preamble}server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name ${domain};
${ssl_block}
client_max_body_size ${NGINX_RELEASE_UPLOAD_BODY_SIZE};
${server_dns}
${idp_api_block}
location / {
${proxy_root}
proxy_http_version 1.1;
${nginx_proxy_headers}
}
}
EOF
fi
nginx_publish_config "fedcm-apex"
}
write_nginx_site_docs() {
local domain="$1"
local ssl_type="${2:-none}"
local conf http_preamble ssl_block proxy_root server_dns
conf="$(nginx_conf_target docs)"
proxy_root="$(render_proxy_root docs)"
http_preamble="$(nginx_http_server_preamble "$domain" "$ssl_type")"
server_dns="$(nginx_docker_server_dns_block)"
if [[ "$ssl_type" == "none" ]]; then
nginx_write_conf "$conf" <<EOF
server {
listen 80;
listen [::]:80;
server_name ${domain};
${server_dns}
location = / { return 302 /docs; }
location / {
${proxy_root}
proxy_http_version 1.1;
${nginx_proxy_headers}
}
}
EOF
else
ssl_block="$(nginx_ssl_directives "$domain" "$ssl_type")"
nginx_write_conf "$conf" <<EOF
${http_preamble}server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name ${domain};
${ssl_block}
${server_dns}
location = / { return 302 /docs; }
location / {
${proxy_root}
proxy_http_version 1.1;
${nginx_proxy_headers}
}
}
EOF
fi
nginx_publish_config "docs"
}
write_nginx_site_ws() {
local domain="$1"
local ssl_type="${2:-none}"
local conf http_preamble ssl_block proxy_ws server_dns
conf="$(nginx_conf_target ws)"
proxy_ws="$(render_proxy_rewrite ws /ws)"
http_preamble="$(nginx_http_server_preamble "$domain" "$ssl_type")"
server_dns="$(nginx_docker_server_dns_block)"
if [[ "$ssl_type" == "none" ]]; then
nginx_write_conf "$conf" <<EOF
server {
listen 80;
listen [::]:80;
server_name ${domain};
${server_dns}
location / {
${proxy_ws}
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_read_timeout 86400s;
}
}
EOF
else
ssl_block="$(nginx_ssl_directives "$domain" "$ssl_type")"
nginx_write_conf "$conf" <<EOF
${http_preamble}server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name ${domain};
${ssl_block}
${server_dns}
location / {
${proxy_ws}
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_read_timeout 86400s;
}
}
EOF
fi
nginx_publish_config "ws"
}
write_nginx_site_minio() {
local domain="$1"
local kind="$2"
local label="$3"
local ssl_type="${4:-none}"
local conf http_preamble ssl_block proxy_root server_dns
conf="$(nginx_conf_target "$label")"
proxy_root="$(render_proxy_root "$kind")"
http_preamble="$(nginx_http_server_preamble "$domain" "$ssl_type")"
server_dns="$(nginx_docker_server_dns_block)"
if [[ "$ssl_type" == "none" ]]; then
nginx_write_conf "$conf" <<EOF
server {
listen 80;
listen [::]:80;
server_name ${domain};
client_max_body_size 1024m;
${server_dns}
proxy_buffering off;
location / {
${proxy_root}
proxy_http_version 1.1;
${nginx_proxy_headers}
}
}
EOF
else
ssl_block="$(nginx_ssl_directives "$domain" "$ssl_type")"
nginx_write_conf "$conf" <<EOF
${http_preamble}server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name ${domain};
${ssl_block}
client_max_body_size 1024m;
${server_dns}
proxy_buffering off;
location / {
${proxy_root}
proxy_http_version 1.1;
${nginx_proxy_headers}
}
}
EOF
fi
nginx_publish_config "$label"
}
remove_nginx_configs() {
log "Удаление конфигов Nginx IdP..."
rm -f "${LOCAL_CONF_DIR}/${NGINX_PREFIX}-"*.conf 2>/dev/null || true
if [[ "$NGINX_MODE" == "host" ]] || [[ "${FORCE_DOCKER_NGINX:-0}" -eq 1 ]]; then
run_root rm -f \
"${NGINX_ENABLED}/${NGINX_PREFIX}-"*.conf \
"${NGINX_AVAILABLE}/${NGINX_PREFIX}-"*.conf 2>/dev/null || true
run_root nginx -t >/dev/null 2>&1 && run_root systemctl reload nginx || true
fi
ok "Конфиги Nginx IdP удалены"
}
# Перед записью конфигов: docker-режим если lendry-id-nginx или FORCE_DOCKER / NGINX_MODE=docker в .env
ensure_nginx_mode_for_config_write() {
load_env
if [[ "${FORCE_DOCKER_NGINX:-0}" -eq 1 ]] \
|| [[ "$(env_get NGINX_MODE docker)" == "docker" ]] \
|| docker_nginx_container_running; then
NGINX_MODE="docker"
env_set NGINX_MODE "docker"
elif [[ "$NGINX_MODE_CLI" == "host" ]] && [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
NGINX_MODE="host"
else
detect_nginx_mode
NGINX_MODE="docker"
env_set NGINX_MODE "docker"
fi
log "Запись конфигов Nginx: mode=${NGINX_MODE}, каталог=${LOCAL_CONF_DIR}"
}
validate_docker_api_nginx_config() {
[[ "$NGINX_MODE" == "docker" ]] || return 0
local conf="${LOCAL_CONF_DIR}/${NGINX_PREFIX}-api.conf"
[[ -f "$conf" ]] || fail "Не найден ${conf} — конфиг API-домена не записан"
if grep -q "upstream ${NGINX_PREFIX}_api" "$conf" 2>/dev/null; then
fail "Конфиг ${conf} содержит legacy upstream{} — выполните ./install.sh --fix-all"
fi
if ! grep -q 'api-gateway:3000' "$conf" 2>/dev/null; then
fail "Конфиг ${conf} без proxy_pass на api-gateway:3000 — выполните ./install.sh --fix-all"
fi
if grep -q 'proxy_pass.*\$request_uri' "$conf" 2>/dev/null; then
fail "Конфиг ${conf} содержит proxy_pass \$request_uri — нужен ./install.sh --fix-all (502 на / без resolver)"
fi
if grep -q 'proxy_pass \$' "$conf" 2>/dev/null && ! grep -q 'resolver 127\.0\.0\.11' "$conf" 2>/dev/null; then
fail "Конфиг ${conf} использует variable proxy_pass без Docker resolver — будет 502 на api.idpmvk.lpr. Выполните ./install.sh --fix-all"
fi
ok "Конфиг API-домена: proxy_pass → api-gateway:3000"
}
validate_docker_frontend_nginx_config() {
[[ "$NGINX_MODE" == "docker" ]] || return 0
local conf="${LOCAL_CONF_DIR}/${NGINX_PREFIX}-frontend.conf"
[[ -f "$conf" ]] || fail "Не найден ${conf} — конфиг SSO-домена не записан"
if ! grep -q 'location \^~ /idp-api/' "$conf" 2>/dev/null && ! grep -q 'location.*idp-api' "$conf" 2>/dev/null; then
fail "Конфиг ${conf} без location /idp-api — браузер SPA получит 502. Выполните ./install.sh --fix-all"
fi
if ! grep -q 'api-gateway:3000' "$conf" 2>/dev/null; then
fail "Конфиг ${conf} без proxy_pass на api-gateway:3000 — выполните ./install.sh --fix-all"
fi
if ! grep -q 'location \^~ /idp-api/ws' "$conf" 2>/dev/null; then
warn "Конфиг ${conf}: location ^~ /idp-api/ws отсутствует — WebSocket может идти на api-gateway вместо media-ws"
fi
ok "Конфиг SSO-домена: /idp-api → api-gateway:3000"
}
validate_all_docker_nginx_proxy_pass() {
[[ "$NGINX_MODE" == "docker" ]] || return 0
local conf
shopt -s nullglob
for conf in "${LOCAL_CONF_DIR}/${NGINX_PREFIX}-"*.conf; do
[[ -f "$conf" ]] || continue
if grep -q 'proxy_pass.*\$request_uri' "$conf" 2>/dev/null; then
fail "Конфиг $(basename "$conf") содержит proxy_pass \$request_uri — 502 «no resolver defined» на /. Выполните ./install.sh --fix-all"
fi
if grep -q 'proxy_pass \$' "$conf" 2>/dev/null && ! grep -q 'resolver 127\.0\.0\.11' "$conf" 2>/dev/null; then
fail "Конфиг $(basename "$conf") использует variable proxy_pass без resolver 127.0.0.11 — после recreate контейнеров будут 502"
fi
done
shopt -u nullglob
}
verify_nginx_reaches_api_gateway() {
docker_nginx_container_running || return 0
build_compose_stack_cmd
local max_attempts="${1:-1}"
local delay_sec="${2:-0}"
local attempt=0
local body gw_ip
while [[ $attempt -lt $max_attempts ]]; do
gw_ip="$(docker_cmd inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' lendry-id-api-gateway 2>/dev/null || true)"
body="$(docker_cmd "${COMPOSE_STACK_CMD[@]}" exec -T nginx \
wget -qO- --timeout=5 http://api-gateway:3000/health 2>/dev/null || true)"
if [[ "$body" == *'"status":"ok"'* ]] || [[ "$body" == *'"status": "ok"'* ]]; then
ok "Docker Nginx → api-gateway:3000/health OK (IP api-gateway: ${gw_ip:-?})"
verify_nginx_idp_api_location || warn "location /idp-api/ — проверьте SSL и nginx/conf.d (upstream OK)"
return 0
fi
attempt=$((attempt + 1))
if [[ $attempt -lt $max_attempts ]]; then
[[ "$delay_sec" -gt 0 ]] && sleep "$delay_sec"
fi
done
warn "Docker Nginx не достучался до api-gateway:3000/health (IP: ${gw_ip:-?}, попыток: ${max_attempts})"
warn "Проверьте: docker compose --profile proxy ps api-gateway nginx && docker compose logs --tail=40 api-gateway nginx"
return 1
}
# Тот же путь, что в браузере: location ^~ /idp-api/ → api-gateway.
verify_nginx_idp_api_location() {
docker_nginx_container_running || return 0
load_env
local frontend_domain body code ssl_type
frontend_domain="$(env_get DOMAIN_FRONTEND "")"
[[ -n "$frontend_domain" ]] || return 0
ssl_type="$(env_get SSL_TYPE none)"
body="$(nginx_probe_idp_api_health_from_container "$frontend_domain")"
if [[ "$body" == *'"status":"ok"'* ]] || [[ "$body" == *'"status": "ok"'* ]]; then
ok "Docker Nginx location /idp-api/ → api-gateway OK (Host: ${frontend_domain})"
return 0
fi
reload_docker_nginx
sleep 2
body="$(nginx_probe_idp_api_health_from_container "$frontend_domain")"
if [[ "$body" == *'"status":"ok"'* ]] || [[ "$body" == *'"status": "ok"'* ]]; then
ok "Docker Nginx location /idp-api/ → api-gateway OK после reload"
return 0
fi
# Fallback: проверка через локальный Docker Nginx (127.0.0.1), без DNS на сервере
if [[ "$(env_get USE_NGINX_SSL false)" == "true" || "$ssl_type" != "none" ]]; then
code="$(curl_local_nginx_code "$frontend_domain" "/idp-api/health" 1 6)"
else
code="$(curl_local_nginx_code "$frontend_domain" "/idp-api/health" 0 6)"
fi
if [[ "$code" == "200" ]]; then
ok "SSO /idp-api/health → 200 (через lendry-id-nginx на 127.0.0.1)"
return 0
fi
if ! docker_nginx_configs_present; then
warn "nginx/conf.d пуст — выполните: ./install.sh --fix-all"
return 1
fi
warn "Nginx location /idp-api/ не проксирует на api-gateway (Host: ${frontend_domain}, HTTP ${code:-?})"
warn "Проверьте: curl -sk https://${frontend_domain}/idp-api/health"
warn " и: docker compose --env-file .env --profile proxy exec nginx wget -qO- --no-check-certificate --header=\"Host: ${frontend_domain}\" https://127.0.0.1/idp-api/health"
return 1
}
verify_nginx_reaches_frontend() {
docker_nginx_container_running || return 0
build_compose_stack_cmd
load_env
local max_attempts="${1:-1}"
local delay_sec="${2:-0}"
local attempt=0
local body fe_ip frontend_domain code
frontend_domain="$(env_get DOMAIN_FRONTEND "")"
while [[ $attempt -lt $max_attempts ]]; do
fe_ip="$(docker_cmd inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' lendry-id-frontend 2>/dev/null || true)"
body="$(docker_cmd "${COMPOSE_STACK_CMD[@]}" exec -T nginx \
wget -qO- --timeout=6 http://frontend:3000/ 2>/dev/null | head -c 256 || true)"
if [[ -n "$body" ]]; then
ok "Docker Nginx → frontend:3000/ OK (IP frontend: ${fe_ip:-?})"
if [[ -n "$frontend_domain" ]]; then
verify_nginx_frontend_root_location "$frontend_domain" || warn "location / — проверьте SSL и nginx/conf.d (upstream OK)"
fi
return 0
fi
attempt=$((attempt + 1))
if [[ $attempt -lt $max_attempts ]]; then
[[ "$delay_sec" -gt 0 ]] && sleep "$delay_sec"
fi
done
warn "Docker Nginx не достучался до frontend:3000/ (IP: ${fe_ip:-?}, попыток: ${max_attempts})"
warn "Проверьте: docker compose --env-file .env --profile proxy logs --tail=40 frontend nginx"
return 1
}
# location / → frontend (путь браузера на https://sso.../)
verify_nginx_frontend_root_location() {
local frontend_domain="$1"
local body code ssl_type
ssl_type="$(env_get SSL_TYPE none)"
body="$(docker_cmd "${COMPOSE_STACK_CMD[@]}" exec -T nginx \
wget -qO- --timeout=8 --no-check-certificate \
--header="Host: ${frontend_domain}" \
"https://127.0.0.1/" 2>/dev/null | head -c 256 || true)"
if [[ -n "$body" ]]; then
ok "Docker Nginx location / → frontend OK (Host: ${frontend_domain})"
return 0
fi
body="$(docker_cmd "${COMPOSE_STACK_CMD[@]}" exec -T nginx \
wget -qO- --timeout=8 \
--header="Host: ${frontend_domain}" \
"http://127.0.0.1/" 2>/dev/null | head -c 256 || true)"
if [[ -n "$body" ]]; then
ok "Docker Nginx location / → frontend OK по HTTP (Host: ${frontend_domain})"
return 0
fi
if [[ "$(env_get USE_NGINX_SSL false)" == "true" || "$ssl_type" != "none" ]]; then
code="$(curl_local_nginx_code "$frontend_domain" "/" 1 8)"
else
code="$(curl_local_nginx_code "$frontend_domain" "/" 0 8)"
fi
if [[ "$code" == "200" || "$code" == "304" ]]; then
ok "SSO / → HTTP ${code} (через lendry-id-nginx на 127.0.0.1)"
return 0
fi
if ! docker_nginx_configs_present; then
warn "nginx/conf.d пуст — выполните: ./install.sh --fix-all"
return 1
fi
warn "Nginx location / не проксирует на frontend (Host: ${frontend_domain}, HTTP ${code:-?})"
warn "Проверьте: curl -sk -o /dev/null -w '%{http_code}' https://${frontend_domain}/"
return 1
}
check_sso_frontend_root() {
load_env
local frontend_domain code ssl_type
frontend_domain="$(env_get DOMAIN_FRONTEND "")"
[[ -n "$frontend_domain" ]] || return 0
ssl_type="$(env_get SSL_TYPE none)"
if [[ "$(env_get USE_NGINX_SSL false)" == "true" || "$ssl_type" != "none" ]]; then
code="$(curl_local_nginx_code "$frontend_domain" "/" 1 8)"
else
code="$(curl_local_nginx_code "$frontend_domain" "/" 0 8)"
fi
if [[ "$code" == "200" || "$code" == "304" ]]; then
echo -e " ${GREEN}${NC} SSO / → ${code} (главная страница SPA)"
return 0
elif [[ "$code" == "502" ]]; then
echo -e " ${RED}${NC} SSO / → 502 — Nginx не проксирует / на frontend (502 Bad Gateway в браузере)"
echo " Проверьте: docker compose --env-file .env --profile proxy exec nginx wget -qO- http://frontend:3000/"
echo " Решение: docker compose --env-file .env --profile proxy restart nginx frontend"
return 1
else
echo -e " ${YELLOW}!${NC} SSO / → HTTP ${code} (ожидалось 200)"
return 1
fi
}
verify_nginx_reaches_media_ws() {
docker_nginx_container_running || return 0
build_compose_stack_cmd
local body
body="$(docker_cmd "${COMPOSE_STACK_CMD[@]}" exec -T nginx \
wget -qO- --timeout=5 http://media-ws:8085/health 2>/dev/null || true)"
if [[ "$body" == *ok* ]] || [[ "$body" == *healthy* ]]; then
ok "Docker Nginx → media-ws:8085/health OK"
return 0
fi
warn "Docker Nginx не достучался до media-ws:8085"
warn "Проверьте: docker compose ps media-ws && docker compose logs --tail=40 media-ws"
return 1
}
# Проверка того же пути, что использует браузер после логина: https://sso.../idp-api/*
check_sso_idp_api_proxy() {
load_env
local frontend_domain code body
frontend_domain="$(env_get DOMAIN_FRONTEND "")"
[[ -n "$frontend_domain" ]] || return 0
if docker_nginx_container_running; then
body="$(docker_cmd exec lendry-id-frontend \
wget -qO- --timeout=5 http://api-gateway:3000/health 2>/dev/null || true)"
if [[ "$body" == *'"status":"ok"'* ]] || [[ "$body" == *'"status": "ok"'* ]]; then
echo -e " ${GREEN}${NC} frontend → api-gateway:3000/health OK (fallback через Next.js rewrites)"
else
echo -e " ${RED}${NC} frontend → api-gateway:3000 недоступен (Next.js rewrites тоже дадут 502)"
echo " Проверьте: docker compose exec frontend wget -qO- http://api-gateway:3000/health"
return 1
fi
fi
if [[ "$(env_get USE_NGINX_SSL false)" != "true" ]]; then
code="$(curl_local_nginx_code "$frontend_domain" "/idp-api/health" 0 5)"
else
code="$(curl_local_nginx_code "$frontend_domain" "/idp-api/health" 1 5)"
fi
if [[ "$code" == "200" ]]; then
echo -e " ${GREEN}${NC} SSO /idp-api/health → 200 (путь браузера после логина)"
return 0
elif [[ "$code" == "502" ]]; then
echo -e " ${RED}${NC} SSO /idp-api/health → 502 — Nginx не проксирует /idp-api на api-gateway"
echo " Проверьте: grep -E 'idp-api|idp_up_api|resolver' nginx/conf.d/${NGINX_PREFIX}-frontend.conf"
echo " docker compose --profile proxy exec nginx wget -qO- http://api-gateway:3000/health"
return 1
else
echo -e " ${YELLOW}!${NC} SSO /idp-api/health → HTTP ${code} (ожидалось 200)"
return 1
fi
}
# Несколько подряд проб /idp-api/health — ловит «curl OK, браузер 502» после recreate контейнеров.
verify_sso_idp_api_burst() {
load_env
local frontend_domain="$1" ssl_type i code ok_count=0
frontend_domain="${frontend_domain:-$(env_get DOMAIN_FRONTEND "")}"
[[ -n "$frontend_domain" ]] || return 0
ssl_type="$(env_get SSL_TYPE none)"
for i in 1 2 3 4 5; do
if [[ "$(env_get USE_NGINX_SSL false)" == "true" || "$ssl_type" != "none" ]]; then
code="$(curl -sk --max-time 4 -o /dev/null -w '%{http_code}' "https://${frontend_domain}/idp-api/health" 2>/dev/null || echo 000)"
else
code="$(curl -fsS --max-time 4 -o /dev/null -w '%{http_code}' "http://${frontend_domain}/idp-api/health" 2>/dev/null || echo 000)"
fi
[[ "$code" == "200" ]] && ok_count=$((ok_count + 1))
sleep 0.25
done
if [[ $ok_count -ge 4 ]]; then
ok "SSO /idp-api/health стабилен (${ok_count}/5 → 200)"
return 0
fi
warn "SSO /idp-api/health нестабилен (${ok_count}/5 → 200) — возможны 502 в браузере"
warn "Выполните: docker compose --env-file .env --profile proxy exec nginx nginx -s reload"
return 1
}
# FedCM: discover.json на API-домене и /.well-known/web-identity на eTLD+1 (корневой домен).
verify_fedcm_endpoints() {
load_env
local api_domain frontend_domain apex_domain ssl_type code body scheme curl_extra
api_domain="$(env_get DOMAIN_API "")"
frontend_domain="$(env_get DOMAIN_FRONTEND "")"
[[ -n "$api_domain" ]] || return 0
ssl_type="$(env_get SSL_TYPE none)"
scheme="http"
curl_extra=""
if [[ "$(env_get USE_NGINX_SSL false)" == "true" || "$ssl_type" != "none" ]]; then
scheme="https"
curl_extra="-k"
fi
code="$(curl ${curl_extra} -fsS --max-time 8 -o /dev/null -w '%{http_code}' "${scheme}://${api_domain}/fedcm/discover.json" 2>/dev/null || echo 000)"
if [[ "$code" == "200" ]]; then
echo -e " ${GREEN}${NC} FedCM discover.json → 200 (${api_domain})"
elif [[ "$code" == "502" ]]; then
echo -e " ${RED}${NC} FedCM discover.json → 502 (${api_domain}) — Nginx не проксирует /fedcm на api-gateway"
return 1
else
echo -e " ${YELLOW}!${NC} FedCM discover.json → HTTP ${code} (${api_domain}, ожидалось 200)"
fi
local config_login_url config_login_url_alias
config_login_url="$(curl ${curl_extra} -fsS --max-time 8 \
-H "Sec-Fetch-Dest: webidentity" \
"${scheme}://${api_domain}/fedcm/config.json" 2>/dev/null \
| jq -r '.login_url // empty' 2>/dev/null || true)"
config_login_url_alias="$(curl ${curl_extra} -fsS --max-time 8 \
-H "Sec-Fetch-Dest: webidentity" \
"${scheme}://${api_domain}/idp-api/fedcm/config.json" 2>/dev/null \
| jq -r '.login_url // empty' 2>/dev/null || true)"
if [[ -n "$config_login_url" ]]; then
echo -e " ${GREEN}${NC} FedCM config.json login_url=${config_login_url}"
if [[ "$config_login_url" != "https://${api_domain}/auth/login" \
&& "$config_login_url" != "http://${api_domain}/auth/login" \
&& "$config_login_url" != "https://${api_domain}/auth/login?fedcm=1" \
&& "$config_login_url" != "http://${api_domain}/auth/login?fedcm=1" ]]; then
echo -e " ${YELLOW}!${NC} login_url должен быть same-origin с config (${scheme}://${api_domain}/auth/login)"
echo " Иначе Chrome: «invalid URL for login_url». Пересоберите api-gateway и ./install.sh --fix-all"
fi
elif [[ -n "$config_login_url_alias" ]]; then
echo -e " ${YELLOW}!${NC} FedCM config.json: используйте ${scheme}://${api_domain}/fedcm/config.json (не /idp-api/ на API-домене)"
echo -e " ${GREEN}${NC} FedCM config.json (alias /idp-api) login_url=${config_login_url_alias}"
else
echo -e " ${RED}${NC} FedCM config.json без login_url (${api_domain}) — проверьте PUBLIC_FRONTEND_URL и api-gateway"
return 1
fi
apex_domain="$(registrable_domain "${frontend_domain:-$api_domain}")"
if [[ -z "$apex_domain" \
|| "$apex_domain" == "$frontend_domain" \
|| "$apex_domain" == "$api_domain" \
|| "$apex_domain" == "${DOMAIN_DOCS:-}" ]]; then
return 0
fi
code="$(curl ${curl_extra} -fsS --max-time 8 -o /dev/null -w '%{http_code}' "${scheme}://${apex_domain}/.well-known/web-identity" 2>/dev/null || echo 000)"
body="$(curl ${curl_extra} -fsS --max-time 8 "${scheme}://${apex_domain}/.well-known/web-identity" 2>/dev/null || true)"
if [[ "$code" == "200" && "$body" == *provider_urls* ]]; then
echo -e " ${GREEN}${NC} FedCM web-identity → 200 (${apex_domain})"
local well_known_login config_login
well_known_login="$(printf '%s' "$body" | jq -r '.login_url // empty' 2>/dev/null || true)"
config_login="${config_login_url:-$config_login_url_alias}"
if [[ -n "$well_known_login" && -n "$config_login" && "$well_known_login" != "$config_login" ]]; then
echo -e " ${RED}${NC} FedCM login_url не совпадает: web-identity=${well_known_login}, config.json=${config_login}"
return 1
fi
return 0
elif [[ "$code" == "000" ]]; then
echo -e " ${RED}${NC} FedCM web-identity недоступен (${apex_domain}) — ERR_CERT или DNS"
echo " Добавьте ${apex_domain} в hosts/DNS и примите сертификат в браузере на каждом клиенте:"
echo " откройте https://${apex_domain}/.well-known/web-identity → «Дополнительно» → принять риск."
if [[ "$(env_get SSL_TYPE none)" == "selfsigned" ]]; then
echo " Надёжнее: импортируйте ${LOCAL_CERTS_DIR}/shared-intranet/fullchain.pem в «Доверенные корневые центры»."
fi
return 1
elif [[ "$code" == "502" ]]; then
echo -e " ${RED}${NC} FedCM web-identity → 502 (${apex_domain}) — проверьте nginx/conf.d/${NGINX_PREFIX}-fedcm-apex.conf"
return 1
else
echo -e " ${YELLOW}!${NC} FedCM web-identity → HTTP ${code} (${apex_domain}, ожидалось 200 с provider_urls)"
return 1
fi
}
validate_ssl_cert_covers_apex() {
load_env
local apex_domain cert ssl_type sans
apex_domain="$(registrable_domain "${DOMAIN_FRONTEND:-$DOMAIN_API}")"
[[ -n "$apex_domain" ]] || return 0
if [[ "$apex_domain" == "${DOMAIN_FRONTEND:-}" || "$apex_domain" == "${DOMAIN_API:-}" ]]; then
return 0
fi
ssl_type="$(env_get SSL_TYPE none)"
case "$ssl_type" in
selfsigned) cert="${LOCAL_CERTS_DIR}/shared-intranet/fullchain.pem" ;;
custom) cert="${CUSTOM_CERT_TARGET_DIR}/fullchain.pem" ;;
*) return 0 ;;
esac
[[ -f "$cert" ]] || return 0
sans="$(openssl x509 -in "$cert" -noout -ext subjectAltName 2>/dev/null || true)"
if [[ "$sans" == *"${apex_domain}"* ]]; then
return 0
fi
warn "Сертификат не содержит SAN для ${apex_domain} — FedCM выдаст ERR_CERT_COMMON_NAME_INVALID"
warn "Пересоздайте сертификат: ./install.sh --fix-all (selfsigned) или добавьте ${apex_domain} в custom fullchain.pem"
return 1
}
# Синхронизирует PUBLIC_* из .env в SystemSetting — иначе FedCM login_url остаётся localhost:3002.
sync_public_system_settings() {
load_env
[[ -n "${PUBLIC_API_URL:-}" || -n "${PUBLIC_FRONTEND_URL:-}" ]] || return 0
if ! docker_cmd ps --format '{{.Names}}' 2>/dev/null | grep -q '^lendry-id-postgres$'; then
warn "Postgres недоступен — PUBLIC_* в БД не обновлены (FedCM login_url может указывать на localhost)"
return 0
fi
local pg_user pg_pass pg_db sql
pg_user="$(env_get POSTGRES_USER lendry)"
pg_pass="$(env_get POSTGRES_PASSWORD lendry_password)"
pg_db="$(env_get POSTGRES_DB lendry_id)"
sql=""
if [[ -n "${PUBLIC_API_URL:-}" ]]; then
sql+="UPDATE \"SystemSetting\" SET value='${PUBLIC_API_URL}', \"updatedAt\"=NOW() WHERE key='PUBLIC_API_URL';"
fi
if [[ -n "${PUBLIC_FRONTEND_URL:-}" ]]; then
sql+="UPDATE \"SystemSetting\" SET value='${PUBLIC_FRONTEND_URL}', \"updatedAt\"=NOW() WHERE key='PUBLIC_FRONTEND_URL';"
fi
if [[ -n "${DOMAIN_FRONTEND:-}" ]]; then
sql+="UPDATE \"SystemSetting\" SET value='${DOMAIN_FRONTEND}', \"updatedAt\"=NOW() WHERE key='PROJECT_DOMAIN';"
fi
[[ -n "$sql" ]] || return 0
if docker_cmd exec -e PGPASSWORD="$pg_pass" lendry-id-postgres \
psql -U "$pg_user" -d "$pg_db" -v ON_ERROR_STOP=1 -c "$sql" >/dev/null 2>&1; then
ok "SystemSetting: PUBLIC_API_URL / PUBLIC_FRONTEND_URL синхронизированы из .env"
else
warn "Не удалось обновить PUBLIC_* в SystemSetting — проверьте значения в админке → Настройки"
fi
}
write_all_nginx_configs() {
ensure_nginx_mode_for_config_write
local ssl_type="${1:-$(env_get SSL_TYPE none)}"
local ws_on_api="true"
[[ -n "${DOMAIN_WS:-}" && "${DOMAIN_WS}" != "${DOMAIN_API}" ]] && ws_on_api="false"
remove_nginx_configs
write_nginx_site_api "$DOMAIN_API" "$ssl_type" "$ws_on_api"
write_nginx_site_frontend "$DOMAIN_FRONTEND" "$ssl_type"
write_nginx_site_docs "$DOMAIN_DOCS" "$ssl_type"
# FedCM well-known всегда запрашивается с eTLD+1 (idpmvk.lpr). Если фронтенд/API
# живут на поддоменах, поднимаем отдельный server для корневого домена, иначе
# сторонние приложения получают ERR_NAME_NOT_RESOLVED при FedCM-логине.
local apex_domain
apex_domain="$(registrable_domain "${DOMAIN_FRONTEND:-$DOMAIN_API}")"
if [[ -n "$apex_domain" \
&& "$apex_domain" != "$DOMAIN_FRONTEND" \
&& "$apex_domain" != "$DOMAIN_API" \
&& "$apex_domain" != "${DOMAIN_DOCS:-}" ]]; then
write_nginx_site_fedcm_apex "$apex_domain" "$ssl_type"
else
rm -f "${LOCAL_CONF_DIR}/${NGINX_PREFIX}-fedcm-apex.conf" 2>/dev/null || true
run_root rm -f "${NGINX_ENABLED}/${NGINX_PREFIX}-fedcm-apex.conf" "${NGINX_AVAILABLE}/${NGINX_PREFIX}-fedcm-apex.conf" 2>/dev/null || true
fi
if [[ "$ws_on_api" == "false" ]]; then
write_nginx_site_ws "$DOMAIN_WS" "$ssl_type"
else
rm -f "${LOCAL_CONF_DIR}/${NGINX_PREFIX}-ws.conf" 2>/dev/null || true
run_root rm -f "${NGINX_ENABLED}/${NGINX_PREFIX}-ws.conf" "${NGINX_AVAILABLE}/${NGINX_PREFIX}-ws.conf" 2>/dev/null || true
fi
[[ -n "${DOMAIN_MINIO:-}" ]] && write_nginx_site_minio "$DOMAIN_MINIO" minio minio "$ssl_type"
[[ -n "${DOMAIN_MINIO_CONSOLE:-}" ]] && write_nginx_site_minio "$DOMAIN_MINIO_CONSOLE" minio-console minio-console "$ssl_type"
validate_docker_api_nginx_config
validate_docker_frontend_nginx_config
validate_all_docker_nginx_proxy_pass
if [[ "$NGINX_MODE" == "host" ]]; then
if run_root nginx -t; then
run_root systemctl reload nginx || warn "Не удалось перезагрузить Nginx (systemctl reload nginx)"
else
warn "Конфигурация Nginx содержит ошибки — проверьте сертификаты в ${LOCAL_CERTS_DIR}"
fi
else
ok "Конфиги Nginx: ${LOCAL_CONF_DIR}"
reload_docker_nginx
fi
}
write_nginx_http_only() {
write_all_nginx_configs "none"
}
write_nginx_ssl_configs() {
load_env
write_all_nginx_configs "$(env_get SSL_TYPE selfsigned)"
}
obtain_certificate() {
local domain="$1"
local email="$2"
[[ -z "$domain" ]] && return 0
log "Получение SSL для ${domain}..."
if ! run_root certbot certonly --webroot -w "$CERTBOT_WEBROOT" \
--email "$email" --agree-tos --no-eff-email \
-d "$domain" --non-interactive; then
fail "Не удалось получить SSL для ${domain}. Проверьте DNS A-запись → IP сервера и порты 80/443."
fi
ok "Сертификат для ${domain} готов"
}
obtain_all_certificates() {
load_env
[[ "$(env_get SSL_TYPE none)" == "letsencrypt" ]] || return 0
local email="${CERTBOT_EMAIL:-}"
[[ -n "$email" ]] || fail "CERTBOT_EMAIL не задан"
write_nginx_http_only
obtain_certificate "$DOMAIN_API" "$email"
obtain_certificate "$DOMAIN_FRONTEND" "$email"
obtain_certificate "$DOMAIN_DOCS" "$email"
# Сертификат для eTLD+1 (FedCM well-known), если он отдельный server.
local apex_domain
apex_domain="$(registrable_domain "${DOMAIN_FRONTEND:-$DOMAIN_API}")"
if [[ -n "$apex_domain" \
&& "$apex_domain" != "$DOMAIN_FRONTEND" \
&& "$apex_domain" != "$DOMAIN_API" \
&& "$apex_domain" != "${DOMAIN_DOCS:-}" ]]; then
obtain_certificate "$apex_domain" "$email"
fi
if [[ -n "${DOMAIN_WS:-}" && "${DOMAIN_WS}" != "${DOMAIN_API}" ]]; then
obtain_certificate "$DOMAIN_WS" "$email"
fi
[[ -n "${DOMAIN_MINIO:-}" ]] && obtain_certificate "$DOMAIN_MINIO" "$email"
[[ -n "${DOMAIN_MINIO_CONSOLE:-}" ]] && obtain_certificate "$DOMAIN_MINIO_CONSOLE" "$email"
if [[ ! -f /etc/letsencrypt/options-ssl-nginx.conf ]]; then
run_root mkdir -p /etc/letsencrypt
if [[ "$OFFLINE" -ne 1 ]]; then
run_root curl -fsSL https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf \
-o /etc/letsencrypt/options-ssl-nginx.conf 2>/dev/null || true
fi
run_root openssl dhparam -out /etc/letsencrypt/ssl-dhparams.pem 2048 2>/dev/null || true
fi
write_all_nginx_configs "letsencrypt"
setup_certbot_renewal
}
setup_certbot_renewal() {
if has_cmd systemctl && systemctl list-unit-files 2>/dev/null | grep -q certbot.timer; then
run_root systemctl enable certbot.timer >/dev/null 2>&1 || true
run_root systemctl start certbot.timer >/dev/null 2>&1 || true
ok "Автообновление SSL (certbot.timer) включено"
else
warn "certbot.timer не найден — настройте cron: certbot renew --quiet && systemctl reload nginx"
fi
}
setup_nginx_proxy() {
load_env
[[ -n "${DOMAIN_API:-}" ]] || return 0
[[ "$INSTALL_LOCAL" -eq 1 ]] && return 0
resolve_nginx_mode_and_ports
write_compose_override
ensure_docker_nginx_not_conflicting
install_nginx_certbot
local ssl_type
ssl_type="$(env_get SSL_TYPE none)"
case "$ssl_type" in
none)
write_nginx_http_only
ok "Nginx: HTTP — локальная сеть, интернет не нужен"
;;
selfsigned)
generate_all_self_signed_certs
write_all_nginx_configs "selfsigned"
ok "Nginx: HTTPS самоподписанный — импортируйте CA в доверенные на клиентах или примите предупреждение браузера"
;;
custom)
install_custom_certificates
write_all_nginx_configs "custom"
ok "Nginx: HTTPS со своими сертификатами из ${CUSTOM_CERT_TARGET_DIR}/"
;;
letsencrypt)
setup_firewall
install_certbot
run_root mkdir -p "$CERTBOT_WEBROOT"
obtain_all_certificates
;;
*)
fail "Неизвестный SSL_TYPE: $ssl_type"
;;
esac
}
setup_nginx_stack() {
setup_nginx_proxy
}
wait_for_service() {
local name="$1"
local max_attempts="${2:-60}"
local attempt=0
log "Ожидание сервиса ${name}..."
while [[ $attempt -lt $max_attempts ]]; do
if docker_cmd compose ps --status running "$name" 2>/dev/null | grep -q "$name"; then
local cid health
cid="$(docker_cmd compose ps -q "$name" 2>/dev/null | head -n1)"
health="$(docker_cmd inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$cid" 2>/dev/null || echo "none")"
if [[ "$health" == "healthy" || "$health" == "none" ]]; then
ok "Сервис ${name} готов"
return 0
fi
fi
attempt=$((attempt + 1))
sleep 5
done
warn "Сервис ${name} не стал healthy — проверьте: docker compose logs $name"
}
deploy_stack() {
prepare_compose_stack
verify_postgres_auth
log "Сборка и запуск контейнеров (1020 минут при первом запуске)..."
if compose_uses_proxy_profile; then
deploy_phased_stack
else
compose_build_and_up
recreate_host_port_services
ensure_host_upstreams
wait_for_service "sso-core" 120
wait_for_service "api-gateway" 90
wait_for_api_grpc_ready 60 || warn "gRPC api-gateway → sso-core не готов — API может отдавать 502"
wait_for_service "frontend" 60
wait_for_service "docs" 60
fi
}
save_install_state() {
local ts
ts="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
cat >"$STATE_FILE" <<EOF
{"version":"${SCRIPT_VERSION}","updated_at":"${ts}","domains":{"api":"${DOMAIN_API:-}","frontend":"${DOMAIN_FRONTEND:-}","docs":"${DOMAIN_DOCS:-}","ws":"${DOMAIN_WS:-}"}}
EOF
}
print_intranet_hint() {
load_env
[[ "$(env_get INSTALL_MODE local)" == "intranet" ]] || return 0
local server_ip
server_ip="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
[[ -z "$server_ip" ]] && server_ip="<IP-сервера>"
local apex_domain extra_apex=""
apex_domain="$(registrable_domain "${DOMAIN_FRONTEND:-$DOMAIN_API}")"
if [[ -n "$apex_domain" \
&& "$apex_domain" != "$DOMAIN_FRONTEND" \
&& "$apex_domain" != "$DOMAIN_API" \
&& "$apex_domain" != "${DOMAIN_DOCS:-}" ]]; then
extra_apex=" ${apex_domain}"
fi
echo -e " ${BOLD}Локальная сеть (DNS / hosts):${NC}"
echo " Пропишите во внутреннем DNS или C:\\Windows\\System32\\drivers\\etc\\hosts:"
echo " ${server_ip} ${DOMAIN_API} ${DOMAIN_FRONTEND} ${DOMAIN_DOCS}${extra_apex}"
if [[ -n "$extra_apex" ]]; then
echo " (${apex_domain} обязателен для входа через FedCM/One Tap из сторонних приложений —"
echo " браузер берёт /.well-known/web-identity именно с корневого домена)"
fi
echo ""
if [[ "$(env_get SSL_TYPE none)" == "selfsigned" ]]; then
echo " Сертификат: ${LOCAL_CERTS_DIR}/shared-intranet (общий для всех доменов IdP)"
echo " FedCM/One Tap: браузер открывает ${apex_domain:-корневой домен}/.well-known/web-identity —"
echo " примите сертификат для ${DOMAIN_FRONTEND}, ${DOMAIN_API}${extra_apex} и доменов OAuth-приложений."
echo " SAN OAuth-приложений подтягиваются автоматически из redirect_uri (файл oauth-redirect-sans.txt)."
echo " Надёжно: импортируйте fullchain.pem в «Доверенные корневые центры» на каждом ПК (Windows/macOS)."
if [[ -n "$(env_get EXTRA_SSL_SANS "")" ]]; then
echo " EXTRA_SSL_SANS (ручной доп.): $(env_get EXTRA_SSL_SANS "")"
fi
echo " После нового OAuth-клиента: ./install.sh --fix-all (или обновление SSL) пересоздаст сертификат."
echo ""
fi
if [[ "$(env_get LDAP_USE_HOST_NETWORK false)" == "true" ]]; then
echo " LDAP: контейнер ldap-auth использует сеть хоста (резолвит AD-имена как сервер)."
elif [[ -n "$(env_get LDAP_DNS_SERVERS "")" || -n "$(env_get LDAP_EXTRA_HOSTS "")" ]]; then
echo " LDAP: для ldap-auth настроены DNS/extra_hosts из .env"
else
echo " LDAP: если AD-имена не резолвятся — LDAP_USE_HOST_NETWORK=true или IP DC в LDAP_HOST."
fi
echo ""
}
print_summary() {
load_env
echo ""
echo -e "${GREEN}══════════════════════════════════════════════════════════════${NC}"
echo -e "${GREEN} Lendry ID — развёртывание завершено${NC}"
echo -e "${GREEN}══════════════════════════════════════════════════════════════${NC}"
echo ""
echo -e " ${BOLD}Режим:${NC} ${INSTALL_MODE:-local} | SSL: ${SSL_TYPE:-none} | Nginx: ${NGINX_MODE:-docker}"
echo -e " ${BOLD}API / Swagger:${NC} ${PUBLIC_API_URL}/docs"
echo -e " ${BOLD}Frontend:${NC} ${PUBLIC_FRONTEND_URL}"
echo -e " ${BOLD}Документация:${NC} ${PUBLIC_DOCS_URL}/docs"
echo -e " ${BOLD}WebSocket:${NC} ${PUBLIC_WS_URL}"
echo ""
print_intranet_hint
echo -e " ${BOLD}Управление:${NC} ./install.sh"
echo -e " ${BOLD}После git pull:${NC} ./install.sh --migrate-rebuild"
echo -e " ${BOLD}Смена доменов:${NC} ./install.sh --configure-domains"
echo -e " ${BOLD}Логи:${NC} docker compose logs -f api-gateway nginx"
echo -e " ${BOLD}Очистка Docker:${NC} ./install.sh --docker-prune"
echo -e " ${BOLD}С нуля:${NC} ./install.sh --fresh-install --yes"
echo ""
echo " Первый зарегистрированный пользователь станет супер-администратором."
echo " Секреты в .env — не коммитьте в git."
echo ""
if [[ "${DOCKER_SUGGESTED_GROUP:-}" == "1" ]]; then
warn "Docker без sudo: выполните newgrp docker или перелогиньтесь"
fi
}
refresh_host_nginx_configs() {
load_env
[[ "$(env_get NGINX_MODE docker)" == "host" ]] || return 0
[[ -n "$(env_get DOMAIN_API "")" ]] || return 0
[[ "$(env_get INSTALL_MODE local)" == "local" ]] && return 0
local ssl_type
ssl_type="$(env_get SSL_TYPE none)"
case "$ssl_type" in
none) write_nginx_http_only || warn "Не удалось записать HTTP-конфиги Nginx (нужен sudo?)" ;;
selfsigned)
generate_all_self_signed_certs
write_all_nginx_configs "selfsigned" || warn "Не удалось обновить Nginx (нужен sudo?)"
;;
custom)
install_custom_certificates
write_all_nginx_configs "custom" || warn "Не удалось обновить Nginx (нужен sudo?)"
;;
letsencrypt)
write_all_nginx_configs "letsencrypt" || warn "Не удалось обновить Nginx (нужен sudo?)"
;;
esac
}
docker_nginx_configs_present() {
[[ -f "${LOCAL_CONF_DIR}/${NGINX_PREFIX}-frontend.conf" ]] \
&& [[ -f "${LOCAL_CONF_DIR}/${NGINX_PREFIX}-api.conf" ]]
}
# Генерирует ./nginx/conf.d при переходе с host Nginx или первом запуске Docker Nginx.
ensure_docker_nginx_configs() {
load_env
[[ "$(env_get NGINX_MODE docker)" == "docker" ]] || return 0
[[ -n "$(env_get DOMAIN_API "")" && -n "$(env_get DOMAIN_FRONTEND "")" ]] || return 0
[[ "$(env_get INSTALL_MODE local)" == "local" ]] && return 0
docker_nginx_configs_present && return 0
warn "Конфиги Docker Nginx отсутствуют в ${LOCAL_CONF_DIR} — генерируем..."
DOMAIN_API="$(env_get DOMAIN_API "")"
DOMAIN_FRONTEND="$(env_get DOMAIN_FRONTEND "")"
DOMAIN_DOCS="$(env_get DOMAIN_DOCS "")"
DOMAIN_WS="$(env_get DOMAIN_WS "")"
DOMAIN_MINIO="$(env_get DOMAIN_MINIO "")"
DOMAIN_MINIO_CONSOLE="$(env_get DOMAIN_MINIO_CONSOLE "")"
NGINX_MODE="docker"
FORCE_DOCKER_NGINX=1
local ssl_type
ssl_type="$(env_get SSL_TYPE none)"
case "$ssl_type" in
none)
write_nginx_http_only
;;
custom)
install_custom_certificates || warn "Не удалось установить custom-сертификаты"
write_all_nginx_configs "custom"
;;
selfsigned)
generate_all_self_signed_certs || warn "Не удалось создать self-signed сертификаты"
write_all_nginx_configs "selfsigned"
;;
letsencrypt)
write_all_nginx_configs "letsencrypt"
;;
*)
write_nginx_http_only
;;
esac
ok "Конфиги Docker Nginx записаны в ${LOCAL_CONF_DIR}"
}
# HTTP-код через локальный lendry-id-nginx (--resolve 127.0.0.1, без DNS домена на сервере).
curl_local_nginx_code() {
local domain="$1" path="$2" use_ssl="${3:-1}" timeout="${4:-8}"
local port=443
local -a curl_opts=(-sk --max-time "$timeout" -o /dev/null -w '%{http_code}')
if [[ "$use_ssl" != "1" ]]; then
port=80
curl_opts=(-fsS --max-time "$timeout" -o /dev/null -w '%{http_code}')
fi
curl "${curl_opts[@]}" --resolve "${domain}:${port}:127.0.0.1" \
"$([[ "$use_ssl" == "1" ]] && echo "https" || echo "http")://${domain}${path}" 2>/dev/null || echo 000
}
check_local_upstream() {
local label="$1" url="$2"
if curl -fsS --max-time 3 "$url" >/dev/null 2>&1; then
echo -e " ${GREEN}${NC} ${label}: ${url}"
return 0
else
echo -e " ${RED}${NC} ${label}: ${url} — недоступен"
return 1
fi
}
# Readiness api-gateway → sso-core по gRPC (/health не проверяет gRPC).
wait_for_api_grpc_ready() {
local attempt max="${1:-60}" code
for ((attempt = 1; attempt <= max; attempt++)); do
code="$(curl -fsS --max-time 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/health/ready 2>/dev/null || echo 000)"
if [[ "$code" == "200" ]]; then
ok "gRPC api-gateway → sso-core: готов (/health/ready)"
return 0
fi
sleep 2
done
warn "gRPC api-gateway → sso-core не готов (HTTP ${code:-000} на /health/ready)"
return 1
}
check_https_api_with_origin() {
local api_domain frontend_domain origin code
api_domain="$(env_get DOMAIN_API "")"
frontend_domain="$(env_get DOMAIN_FRONTEND "")"
[[ -n "$api_domain" && -n "$frontend_domain" ]] || return 0
[[ "$(env_get USE_NGINX_SSL false)" == "true" ]] || return 0
origin="https://${frontend_domain}"
code="$(curl -sk --max-time 5 -o /dev/null -w '%{http_code}' -H "Origin: ${origin}" \
--resolve "${api_domain}:443:127.0.0.1" "https://${api_domain}/health" 2>/dev/null || echo 000)"
if [[ "$code" == "200" ]]; then
echo -e " ${GREEN}${NC} HTTPS API + Origin: https://${api_domain}/health → 200"
elif [[ "$code" == "502" ]]; then
echo -e " ${RED}${NC} HTTPS API + Origin → 502 (Nginx не проксирует cross-origin на api-домен)"
echo " Решение: ./install.sh --fix-all (перегенерирует конфиг api-домена)"
return 1
else
echo -e " ${YELLOW}!${NC} HTTPS API + Origin: https://${api_domain}/health → HTTP ${code}"
return 1
fi
code="$(curl -sk --max-time 5 -o /dev/null -w '%{http_code}' -H "Origin: ${origin}" \
--resolve "${api_domain}:443:127.0.0.1" "https://${api_domain}/notifications/unread-count" 2>/dev/null || echo 000)"
if [[ "$code" == "401" || "$code" == "403" ]]; then
echo -e " ${GREEN}${NC} HTTPS API + Origin (без токена): /notifications/unread-count → ${code}"
elif [[ "$code" == "502" ]]; then
echo -e " ${RED}${NC} HTTPS API + Origin: /notifications/unread-count → 502 (upstream api-gateway)"
echo " Проверьте: curl -s http://127.0.0.1:3000/health/ready && docker compose logs --tail=50 api-gateway sso-core"
return 1
else
echo -e " ${YELLOW}!${NC} HTTPS API + Origin: /notifications/unread-count → HTTP ${code} (ожидалось 401)"
return 1
fi
code="$(curl -sk --max-time 5 -o /dev/null -w '%{http_code}' -X OPTIONS \
-H "Origin: ${origin}" \
-H "Access-Control-Request-Method: GET" \
--resolve "${api_domain}:443:127.0.0.1" \
"https://${api_domain}/notifications/unread-count" 2>/dev/null || echo 000)"
if [[ "$code" == "204" || "$code" == "200" ]]; then
echo -e " ${GREEN}${NC} HTTPS API preflight (OPTIONS): /notifications/unread-count → ${code}"
elif [[ "$code" == "502" ]]; then
echo -e " ${RED}${NC} HTTPS API preflight (OPTIONS) → 502 — Nginx не достучался до api-gateway"
echo " Проверьте: grep resolver nginx/conf.d/lendry-id-api.conf"
echo " docker compose --profile proxy exec nginx wget -qO- http://api-gateway:3000/health"
return 1
else
echo -e " ${YELLOW}!${NC} HTTPS API preflight (OPTIONS) → HTTP ${code} (ожидалось 204)"
return 1
fi
local test_token
test_token="$(env_get IDP_TEST_BEARER_TOKEN "")"
if [[ -n "$test_token" ]]; then
code="$(curl -sk --max-time 8 -o /dev/null -w '%{http_code}' \
-H "Origin: ${origin}" \
-H "Authorization: Bearer ${test_token}" \
-H "Content-Type: application/json" \
"https://${api_domain}/notifications/unread-count" 2>/dev/null || echo 000)"
if [[ "$code" == "200" || "$code" == "403" ]]; then
echo -e " ${GREEN}${NC} HTTPS API + Bearer token: /notifications/unread-count → ${code}"
elif [[ "$code" == "502" ]]; then
echo -e " ${RED}${NC} HTTPS API + Bearer token → 502 (как в браузере)"
echo " docker compose logs --tail=80 api-gateway sso-core"
return 1
else
echo -e " ${YELLOW}!${NC} HTTPS API + Bearer token → HTTP ${code}"
fi
else
echo -e " ${YELLOW}!${NC} Для проверки с токеном: IDP_TEST_BEARER_TOKEN=<jwt> ./install.sh --fix-all"
echo " (скопируйте lendry_access_token из DevTools → Application → Local Storage на sso)"
fi
return 0
}
# Диагностика типичных проблем: 502 на /idp-api, конфликт nginx, порты upstream.
diagnose_common_issues() {
load_env
local issues=0
echo ""
echo -e "${BOLD}Диагностика${NC}"
echo ""
if docker_nginx_container_running; then
if system_nginx_is_active \
&& [[ "$(env_get NGINX_MODE docker)" == "docker" ]]; then
echo -e " ${RED}${NC} Конфликт: системный Nginx на 80/443 и lendry-id-nginx одновременно — браузер может ходить не туда (502)"
echo " Решение: sudo systemctl stop nginx && ./install.sh --fix-all"
issues=$((issues + 1))
elif [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
echo -e " ${RED}${NC} NGINX_MODE=host устарел — используйте Docker Nginx"
echo " Решение: ./install.sh --fix-docker-nginx"
issues=$((issues + 1))
elif [[ "$(env_get NGINX_MODE docker)" == "docker" ]] \
&& ! docker_nginx_container_running \
&& ( port_in_use "${NGINX_HTTP_PORT:-80}" || port_in_use "${NGINX_HTTPS_PORT:-443}" ); then
echo -e " ${RED}${NC} Порты 80/443 заняты, но lendry-id-nginx не запущен"
echo " Решение: ./install.sh --fix-all"
issues=$((issues + 1))
else
echo -e " ${GREEN}${NC} Docker Nginx: запущен (режим docker)"
fi
if [[ "$(env_get NGINX_MODE docker)" == "docker" ]]; then
shopt -s nullglob
local conf legacy_conf=0
for conf in "${LOCAL_CONF_DIR}/${NGINX_PREFIX}-"*.conf; do
[[ -f "$conf" ]] || continue
if grep -q 'proxy_pass.*\$request_uri' "$conf" 2>/dev/null; then
legacy_conf=1
break
fi
done
shopt -u nullglob
if [[ "$legacy_conf" -eq 1 ]]; then
echo -e " ${RED}${NC} nginx/conf.d: proxy_pass \$request_uri — 502 на / (no resolver defined to resolve lendry-id-frontend)"
echo " Решение: ./install.sh --fix-all"
issues=$((issues + 1))
elif ! docker_nginx_configs_present; then
echo -e " ${RED}${NC} nginx/conf.d пуст — Docker Nginx без vhost (502 в браузере)"
echo " Решение: ./install.sh --fix-all"
issues=$((issues + 1))
fi
fi
elif [[ "$(env_get NGINX_MODE docker)" == "docker" ]] && compose_uses_proxy_profile; then
echo -e " ${YELLOW}!${NC} Docker Nginx не запущен (ожидался в режиме docker)"
issues=$((issues + 1))
elif [[ "$(env_get NGINX_MODE docker)" == "docker" ]] \
&& [[ -f "${LOCAL_CONF_DIR}/${NGINX_PREFIX}-frontend.conf" ]]; then
load_env
if grep -q "upstream ${NGINX_PREFIX}_api" "${LOCAL_CONF_DIR}/${NGINX_PREFIX}-api.conf" 2>/dev/null; then
echo -e " ${RED}${NC} Конфиг API-домена: legacy upstream{} — после пересборки api-gateway будет 502"
echo " Решение: ./install.sh --fix-all"
issues=$((issues + 1))
elif grep -q '172\.28\.50\.10:3000' "${LOCAL_CONF_DIR}/${NGINX_PREFIX}-frontend.conf" 2>/dev/null; then
echo -e " ${RED}${NC} Устаревший конфиг (статический IP 172.28.50.10) — выполните ./install.sh --fix-all"
issues=$((issues + 1))
elif grep -q 'api-gateway:3000' "${LOCAL_CONF_DIR}/${NGINX_PREFIX}-frontend.conf" 2>/dev/null; then
echo -e " ${GREEN}${NC} Nginx /idp-api: proxy_pass → api-gateway:3000"
else
echo -e " ${YELLOW}!${NC} Конфиг SSO без /idp-api proxy — выполните ./install.sh --fix-all"
issues=$((issues + 1))
fi
elif [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
if system_nginx_is_active; then
echo -e " ${GREEN}${NC} Системный Nginx: активен"
elif host_nginx_available; then
echo -e " ${YELLOW}!${NC} Системный Nginx установлен, но не активен — sudo systemctl start nginx"
issues=$((issues + 1))
else
echo -e " ${RED}${NC} NGINX_MODE=host, но системный Nginx не найден"
issues=$((issues + 1))
fi
fi
if [[ "$(env_get NGINX_MODE docker)" == "host" ]] \
&& [[ "$(env_get INSTALL_MODE local)" != "local" ]] \
&& [[ -n "$(env_get DOMAIN_API "")" ]]; then
for port in 3000 3002 3003; do
if ! port_in_use "$port"; then
echo -e " ${RED}${NC} Порт 127.0.0.1:${port} не слушается — host Nginx получит 502"
issues=$((issues + 1))
fi
done
check_local_upstream "API /health" "http://127.0.0.1:3000/health" || issues=$((issues + 1))
check_local_upstream "Frontend" "http://127.0.0.1:3002" || issues=$((issues + 1))
check_local_upstream "Docs" "http://127.0.0.1:3003/docs" || issues=$((issues + 1))
if has_cmd nginx && [[ -f "${NGINX_ENABLED}/${NGINX_PREFIX}-frontend.conf" ]]; then
if run_root nginx -T 2>/dev/null | grep -q 'proxy_pass http://api-gateway:'; then
echo -e " ${RED}${NC} В конфиге host Nginx указан docker-hostname api-gateway — хост его не резолвит"
echo " Решение: ./install.sh --fix-all (перегенерирует proxy_pass на 127.0.0.1)"
issues=$((issues + 1))
elif run_root nginx -T 2>/dev/null | grep -q 'proxy_pass http://127.0.0.1:3000'; then
echo -e " ${GREEN}${NC} /idp-api проксируется на 127.0.0.1:3000"
fi
fi
fi
if docker_cmd ps --format '{{.Names}} {{.Status}}' 2>/dev/null | grep -E 'api-gateway.*(Restarting|unhealthy)' >/dev/null 2>&1; then
echo -e " ${RED}${NC} api-gateway unhealthy или перезапускается — проверьте: docker compose logs api-gateway"
issues=$((issues + 1))
elif docker_cmd ps --format '{{.Names}} {{.Status}}' 2>/dev/null | grep -q 'lendry-id-api-gateway.*Up'; then
echo -e " ${GREEN}${NC} api-gateway: контейнер запущен"
local restart_count started_at
restart_count="$(docker_cmd inspect lendry-id-api-gateway --format '{{.RestartCount}}' 2>/dev/null || echo '?')"
started_at="$(docker_cmd inspect lendry-id-api-gateway --format '{{.State.StartedAt}}' 2>/dev/null || echo '?')"
if [[ "$restart_count" =~ ^[0-9]+$ ]] && [[ "$restart_count" -gt 2 ]]; then
echo -e " ${RED}${NC} api-gateway RestartCount=${restart_count} (Started=${started_at}) — частые перезапуски дают 502 в браузере"
echo " Проверьте: docker compose logs --tail=80 api-gateway sso-core"
issues=$((issues + 1))
else
echo -e " ${GREEN}${NC} api-gateway RestartCount=${restart_count} (Started=${started_at})"
fi
local grpc_code
grpc_code="$(curl -fsS --max-time 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/health/ready 2>/dev/null || echo 000)"
if [[ "$grpc_code" == "200" ]]; then
echo -e " ${GREEN}${NC} api-gateway gRPC → sso-core: /health/ready → 200"
else
echo -e " ${RED}${NC} api-gateway gRPC → sso-core: /health/ready → HTTP ${grpc_code} (без gRPC все API-запросы падают)"
echo " Решение: docker compose restart sso-core api-gateway && ./install.sh --fix-all"
issues=$((issues + 1))
fi
fi
if [[ "$(env_get USE_NGINX_SSL false)" == "true" ]] \
&& [[ -n "$(env_get DOMAIN_API "")" && -n "$(env_get DOMAIN_FRONTEND "")" ]]; then
check_https_api_with_origin || issues=$((issues + 1))
check_sso_idp_api_proxy || issues=$((issues + 1))
check_sso_frontend_root || issues=$((issues + 1))
fi
local api_domain frontend_domain public_api
api_domain="$(env_get DOMAIN_API "")"
frontend_domain="$(env_get DOMAIN_FRONTEND "")"
public_api="$(env_get PUBLIC_API_URL "")"
if [[ -n "$api_domain" && -n "$frontend_domain" && "$api_domain" != "$frontend_domain" ]]; then
if [[ "$public_api" == *"${frontend_domain}"*"/idp-api"* ]]; then
echo -e " ${GREEN}${NC} Split-domain + same-origin SPA: PUBLIC_API_URL=${public_api}, браузер → /idp-api на SSO"
elif [[ "$public_api" == *"${api_domain}"* ]]; then
echo -e " ${GREEN}${NC} Split-domain: PUBLIC_API_URL=${public_api} (OAuth issuer), SPA → /idp-api на SSO"
elif [[ "$public_api" == *"/idp-api"* ]]; then
echo -e " ${YELLOW}!${NC} PUBLIC_API_URL=${public_api} — для OAuth issuer лучше https://${api_domain}"
else
echo -e " ${YELLOW}!${NC} Split-domain: PUBLIC_API_URL=${public_api} — проверьте настройки"
issues=$((issues + 1))
fi
fi
echo ""
if [[ "$issues" -eq 0 ]]; then
ok "Критических проблем не обнаружено"
else
warn "Обнаружено проблем: ${issues}. Запустите: ./install.sh --fix-all"
fi
echo ""
return "$issues"
}
show_upstream_health() {
load_env
[[ "$(env_get NGINX_MODE docker)" != "host" ]] && return 0
[[ "$(env_get INSTALL_MODE local)" == "local" ]] && return 0
[[ -n "$(env_get DOMAIN_API "")" ]] || return 0
echo " Upstream (host Nginx → localhost):"
check_local_upstream "API /health" "http://127.0.0.1:3000/health"
check_local_upstream "API gRPC ready" "http://127.0.0.1:3000/health/ready"
check_local_upstream "Swagger" "http://127.0.0.1:3000/docs"
check_local_upstream "Frontend" "http://127.0.0.1:3002"
check_local_upstream "Docs" "http://127.0.0.1:3003/docs"
if [[ "$(env_get USE_NGINX_SSL false)" == "true" ]]; then
echo ""
echo " HTTPS (как в браузере, с Origin SSO):"
check_https_api_with_origin || true
fi
echo ""
}
show_status() {
load_env
echo ""
echo -e "${BOLD}Статус Lendry ID${NC}"
echo ""
echo " INSTALL_MODE: $(env_get INSTALL_MODE local)"
echo " SSL_TYPE: $(env_get SSL_TYPE none)"
if [[ "$(env_get SSL_TYPE none)" == "custom" ]]; then
echo " SSL_CERT_DIR: $(env_get SSL_CERT_DIR -)"
echo " SSL установлен: ${CUSTOM_CERT_TARGET_DIR}/"
fi
echo " NGINX_MODE: $(env_get NGINX_MODE docker)"
echo " USE_NGINX_SSL: $(env_get USE_NGINX_SSL false)"
echo " DOMAIN_API: $(env_get DOMAIN_API -)"
echo " DOMAIN_FRONTEND: $(env_get DOMAIN_FRONTEND -)"
echo " DOMAIN_DOCS: $(env_get DOMAIN_DOCS -)"
echo " DOMAIN_WS: $(env_get DOMAIN_WS -)"
echo " PUBLIC_API_URL: $(env_get PUBLIC_API_URL -)"
echo " PUBLIC_WS_URL: $(env_get PUBLIC_WS_URL -)"
echo ""
docker_cmd compose ps 2>/dev/null || warn "Контейнеры не запущены"
echo ""
echo -e " ${BOLD}Docker disk:${NC}"
docker_disk_usage_brief
echo ""
if docker_cmd ps -q -f name=lendry-id-nginx 2>/dev/null | grep -q .; then
if [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
warn "NGINX_MODE=host устарел при запущенном lendry-id-nginx — выполните: ./install.sh --fix-docker-nginx"
else
echo " Docker Nginx: запущен (lendry-id-nginx)"
fi
elif [[ "$(env_get NGINX_MODE docker)" == "docker" ]] \
&& [[ "$(env_get INSTALL_MODE local)" != "local" ]] \
&& [[ -n "$(env_get DOMAIN_API "")" ]]; then
warn "Docker Nginx: не запущен — выполните: ./install.sh --fix-docker-nginx"
elif [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
warn "NGINX_MODE=host устарел — выполните: ./install.sh --fix-docker-nginx"
fi
if [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
docker_cmd port lendry-id-frontend 3000/tcp 2>/dev/null | sed 's/^/ frontend port: /' || \
warn "frontend не публикует 3000/tcp на хост — выполните: ./install.sh --fix-all"
fi
echo ""
show_upstream_health
if has_cmd nginx && [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
run_root nginx -t 2>&1 | sed 's/^/ /'
fi
}
action_full_install() {
configure_domains_interactive
persist_domain_env
ensure_docker_nginx_mode_for_install
ensure_split_domain_public_urls
setup_nginx_proxy
deploy_stack
save_install_state
diagnose_common_issues || true
print_summary
}
docker_fresh_purge() {
prepare_compose_stack
build_compose_stack_cmd
log "Остановка стека и удаление volumes (PostgreSQL, Redis, MinIO, RabbitMQ)..."
docker_cmd rm -f lendry-id-nginx-gateway-sync 2>/dev/null || true
docker_cmd "${COMPOSE_STACK_CMD[@]}" down -v --remove-orphans 2>/dev/null || true
docker_cmd compose --env-file .env down -v --remove-orphans 2>/dev/null || true
docker_cmd compose --env-file .env --profile proxy down -v --remove-orphans 2>/dev/null || true
log "Удаление образов, кеша сборки и неиспользуемых volumes..."
docker_cmd container prune -f 2>/dev/null || true
docker_cmd image prune -af 2>/dev/null || true
docker_cmd volume prune -f 2>/dev/null || true
docker_cmd builder prune -af 2>/dev/null || true
docker_prune_stale_project_images "$(compose_project_name)" 1
log "Очистка устаревших конфигов Nginx..."
rm -f "${LOCAL_CONF_DIR}/${NGINX_PREFIX}"*.conf 2>/dev/null || true
disable_host_nginx_lendry
ok "Docker полностью очищен"
docker_disk_usage_brief
}
action_fresh_install() {
echo ""
echo -e "${BOLD}Полная переустановка Lendry ID${NC}"
echo " Будут удалены: все контейнеры проекта, volumes (БД, Redis, MinIO), образы и build cache."
echo " Файл .env сохранится (домены и секреты). Для нового .env: FORCE_ENV=1 ./install.sh --fresh-install"
echo ""
if [[ "$NON_INTERACTIVE" -ne 1 ]]; then
local confirm
read -r -p "Продолжить полную очистку и установку? [y/N]: " confirm
case "${confirm,,}" in
y | yes | д | да) ;;
*) fail "Отменено" ;;
esac
fi
if [[ -f .env ]]; then
load_env
docker_fresh_purge
else
warn ".env не найден — выполняется только очистка Docker..."
docker_fresh_purge 2>/dev/null || docker_cmd system prune -af --volumes 2>/dev/null || true
fi
if [[ "${FORCE_ENV:-0}" -eq 1 ]]; then
warn "FORCE_ENV=1: удаление .env и файлов состояния..."
rm -f .env "$STATE_FILE" "$INFRA_SECRETS_FILE"
fi
SKIP_DOCKER_PRUNE=1
DOCKER_PRUNE_AGGRESSIVE=0
action_full_install
}
action_configure_domains() {
if [[ ! -f .env ]]; then
warn ".env не найден — выполняем полную установку"
action_full_install
return
fi
load_env
configure_domains_interactive
persist_domain_env
if [[ "$INSTALL_LOCAL" -eq 1 ]]; then
remove_nginx_configs
fi
setup_nginx_proxy
write_compose_override
log "Пересборка frontend/docs с новыми URL..."
deploy_stack
save_install_state
print_summary
}
apply_intranet_selfsigned_runtime() {
load_env
[[ "$(env_get SSL_TYPE none)" == "selfsigned" ]] || return 0
[[ -n "$(env_get DOMAIN_FRONTEND "")" ]] || return 0
derive_public_urls "selfsigned"
env_set PUBLIC_API_URL "${PUBLIC_API_URL}"
env_set PUBLIC_WS_URL "${PUBLIC_WS_URL}"
env_set INTERNAL_API_URL "http://api-gateway:3000"
env_set INTERNAL_WS_URL "http://media-ws:8085"
log "Пересборка frontend (same-origin /idp-api)..."
compose_build_and_up frontend
}
action_renew_ssl() {
load_env
local ssl_type
ssl_type="$(env_get SSL_TYPE none)"
if [[ "$ssl_type" == "none" ]]; then
if [[ "$NON_INTERACTIVE" -eq 1 ]]; then
fail "SSL отключён (SSL_TYPE=none). Сначала настройте домены и SSL:
./install.sh --configure-domains --intranet \\
--api-domain api.idpmvk.lpr --frontend-domain sso.idpmvk.lpr --docs-domain docs.idpmvk.lpr \\
--ssl custom --cert-dir /opt/idp-certs"
fi
echo ""
warn "SSL сейчас отключён (SSL_TYPE=none)."
echo ""
echo " a) Настроить домены и SSL (рекомендуется) — откроется пункт 2"
echo " b) Установить / обновить свои сертификаты (custom) — домены должны быть уже в .env"
echo " c) Отмена"
echo ""
local renew_choice
read -r -p "Выберите [a/b/c]: " renew_choice
case "$renew_choice" in
a | A)
action_configure_domains
return
;;
b | B)
ssl_type="custom"
SSL_TYPE="custom"
prompt_custom_cert_settings
[[ -n "$(env_get DOMAIN_API "")" && -n "$(env_get DOMAIN_FRONTEND "")" && -n "$(env_get DOMAIN_DOCS "")" ]] \
|| fail "Домены не заданы. Сначала выполните пункт 2 (Настроить домены) или укажите DOMAIN_API, DOMAIN_FRONTEND, DOMAIN_DOCS в .env"
derive_public_urls "custom"
env_set SSL_TYPE "custom"
env_set USE_NGINX_SSL "true"
env_set SSL_CERT_DIR "${SSL_CERT_DIR}"
env_set SSL_CERT_FULLCHAIN "${SSL_CERT_FULLCHAIN:-}"
env_set SSL_CERT_KEY "${SSL_CERT_KEY:-}"
env_set PUBLIC_API_URL "${PUBLIC_API_URL}"
env_set PUBLIC_FRONTEND_URL "${PUBLIC_FRONTEND_URL}"
env_set PUBLIC_DOCS_URL "${PUBLIC_DOCS_URL}"
env_set PUBLIC_WS_URL "${PUBLIC_WS_URL}"
;;
*)
log "Отменено"
return 0
;;
esac
fi
case "$ssl_type" in
letsencrypt)
install_certbot
run_root certbot renew --quiet
write_all_nginx_configs "letsencrypt"
ok "Let's Encrypt сертификаты обновлены"
;;
selfsigned)
generate_all_self_signed_certs --force
write_all_nginx_configs "selfsigned"
apply_intranet_selfsigned_runtime
ok "Самоподписанные сертификаты пересозданы, frontend обновлён"
;;
custom)
install_custom_certificates --force
write_all_nginx_configs "custom"
if [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
run_root nginx -t >/dev/null 2>&1 && run_root systemctl reload nginx || warn "Перезагрузите Nginx вручную: sudo systemctl reload nginx"
fi
ok "Свои сертификаты установлены из $(env_get SSL_CERT_DIR -)"
;;
none)
fail "SSL отключён (SSL_TYPE=none). Выберите пункт 2 или: ./install.sh --configure-domains --ssl custom --cert-dir /path"
;;
esac
}
action_fix_host_proxy() {
[[ -f .env ]] || fail "Файл .env не найден — сначала выполните ./install.sh --install"
load_env
env_set NGINX_MODE "host"
NGINX_MODE="host"
NON_INTERACTIVE=1
log "Исправление прокси (host Nginx + порты 127.0.0.1)..."
log "Шаг 1/5: остановка Docker Nginx и проверка портов..."
ensure_docker_nginx_not_conflicting
log "Шаг 2/5: compose override (LDAP / Nginx)..."
write_compose_override
ensure_split_domain_public_urls
log "Шаг 3/5: пересоздание api-gateway, frontend, docs, media-ws..."
compose_build_and_up "${HOST_EDGE_SERVICES[@]}"
ensure_host_upstreams
log "Шаг 4/5: конфиги системного Nginx..."
refresh_host_nginx_configs
apply_intranet_selfsigned_runtime
log "Шаг 5/5: пересоздание ldap-auth, sso-core и api-gateway..."
compose_build_and_up ldap-auth sso-core
wait_for_service "sso-core" 120
compose_build_and_up api-gateway
wait_for_service "api-gateway" 90
wait_for_api_grpc_ready 60 || true
sync_nginx_after_api_gateway_restart
ok "Прокси исправлен — проверьте: curl -I http://127.0.0.1:3002"
diagnose_common_issues || true
show_status
}
# Комплексное исправление: 502 на /idp-api, конфликт Docker/host Nginx, порты upstream,
# пересборка api-gateway после смены IP контейнера, актуализация конфигов Nginx.
action_fix_all_errors() {
[[ -f .env ]] || fail "Файл .env не найден — сначала выполните ./install.sh --install"
load_env
NON_INTERACTIVE=1
echo ""
echo -e "${BOLD}Комплексное исправление ошибок IdP${NC}"
echo " (502 Bad Gateway, /idp-api, Docker Nginx lendry-id-nginx, порты upstream)"
echo ""
log "Шаг 1/7: Docker Nginx — остановка system nginx, конфиги, освобождение 80/443..."
ensure_docker_nginx_mode_for_fix_all
ensure_docker_nginx_configs
write_compose_override
ensure_split_domain_public_urls
log "Шаг 2/7: диагностика..."
diagnose_common_issues || true
log "Шаг 3/7: миграции PostgreSQL (Prisma)..."
compose_build_and_up postgres 2>/dev/null || true
run_prisma_migrations || warn "Миграции Prisma не применены — sso-core попробует при старте"
log "Шаг 4/7: пересборка ldap-auth → sso-core → api-gateway..."
compose_build_and_up ldap-auth
wait_for_service "ldap-auth" 90
compose_build_and_up sso-core
wait_for_service "sso-core" 120
compose_build_and_up api-gateway
wait_for_service "api-gateway" 90
wait_for_api_grpc_ready 90 || compose_build_and_up api-gateway
sync_nginx_after_api_gateway_restart
log "Шаг 5/7: пересборка frontend (same-origin /idp-api), docs, media-ws..."
compose_build_and_up frontend docs media-ws
wait_for_service "frontend" 90
wait_for_service "docs" 60
local baked_api
baked_api="$(docker_cmd exec lendry-id-frontend printenv NEXT_PUBLIC_API_URL 2>/dev/null || true)"
log "frontend NEXT_PUBLIC_API_URL=${baked_api:-не задан} (браузер SPA использует /idp-api на SSO)"
log "Шаг 6/7: Docker Nginx — конфиги в ./nginx/conf.d и контейнер lendry-id-nginx..."
ensure_nginx_gateway_watch_script
patch_legacy_nginx_proxy_pass
local ssl_type
ssl_type="$(env_get SSL_TYPE none)"
case "$ssl_type" in
none) write_nginx_http_only || warn "Не удалось записать HTTP-конфиги Nginx" ;;
selfsigned)
renew_self_signed_cert_if_sans_changed
write_all_nginx_configs "selfsigned" || warn "Не удалось обновить конфиги Nginx"
;;
custom)
install_custom_certificates
write_all_nginx_configs "custom" || warn "Не удалось обновить конфиги Nginx"
;;
letsencrypt)
write_all_nginx_configs "letsencrypt" || warn "Не удалось обновить конфиги Nginx"
;;
esac
apply_intranet_selfsigned_runtime
recreate_docker_nginx
sync_nginx_after_api_gateway_restart
sync_nginx_after_edge_restart
verify_nginx_reaches_api_gateway 24 5 || compose_build_and_up api-gateway
sync_nginx_after_api_gateway_restart
verify_nginx_reaches_media_ws || compose_build_and_up media-ws
reload_docker_nginx
verify_nginx_reaches_api_gateway 24 5 || fail "Nginx не проксирует на api-gateway — см. docker compose logs nginx api-gateway"
verify_nginx_reaches_frontend 12 3 || fail "Nginx не проксирует / на frontend — см. docker compose logs nginx frontend"
check_sso_idp_api_proxy || fail "SSO /idp-api/health недоступен — см. nginx/conf.d/${NGINX_PREFIX}-frontend.conf"
check_sso_frontend_root || fail "SSO / → 502 — главная страница недоступна (502 Bad Gateway в браузере)"
verify_sso_idp_api_burst || warn "Нестабильный /idp-api/health — при 502 в браузере: docker compose --profile proxy exec nginx nginx -s reload"
sync_public_system_settings || true
validate_ssl_cert_covers_apex || true
verify_fedcm_endpoints || warn "FedCM endpoints недоступны — см. DNS/SSL для eTLD+1 и nginx/conf.d/${NGINX_PREFIX}-fedcm-apex.conf"
if ! docker_nginx_container_running; then
fail "Контейнер lendry-id-nginx не запущен. Проверьте: docker compose --profile proxy logs nginx"
fi
ok "Docker Nginx запущен — проверьте: docker ps | grep lendry-id-nginx"
log "Шаг 7/7: финальная проверка (HTTPS + CORS preflight)..."
diagnose_common_issues || true
check_https_api_with_origin || warn "Проверка cross-origin API не прошла — см. вывод выше"
ok "Исправление завершено"
echo ""
api_domain="$(env_get DOMAIN_API "")"
frontend_domain="$(env_get DOMAIN_FRONTEND "")"
if [[ -n "$api_domain" && -n "$frontend_domain" && "$api_domain" != "$frontend_domain" ]]; then
echo " Split-domain: в DevTools → Network запросы SPA должны идти на"
echo " https://${frontend_domain}/idp-api/... (same-origin), а не на https://${api_domain}/..."
echo " PUBLIC_API_URL=https://${api_domain} — для OAuth issuer и внешних клиентов."
echo " После пересборки frontend обновите страницу с полным сбросом кеша (Ctrl+Shift+R)."
else
echo " Проверьте в браузере — запросы к /idp-api/* должны отвечать 200."
fi
echo " Если ошибки остались: docker compose logs --tail=80 api-gateway"
echo ""
show_status
}
action_restart() {
load_env
prepare_compose_stack
refresh_host_nginx_configs
log "Пересборка и перезапуск контейнеров..."
compose_build_and_up
recreate_host_port_services
ensure_host_upstreams
ok "Контейнеры перезапущены"
show_status
}
show_menu() {
while true; do
echo ""
echo -e "${BOLD}══════════════════════════════════════════════════════════════${NC}"
echo -e "${BOLD} Lendry ID v${SCRIPT_VERSION} — установка и управление${NC}"
echo -e "${BOLD}══════════════════════════════════════════════════════════════${NC}"
[[ -f .env ]] && load_env && echo -e " Режим: ${INSTALL_MODE:-local} | API: ${DOMAIN_API:-localhost:3000}"
echo ""
echo " 1) Полная установка / переустановка стека"
echo " 10) Полная очистка Docker + установка с нуля (--fresh-install)"
echo " 2) Настроить / изменить домены и Nginx + SSL"
echo " 3) SSL-сертификаты (обновить / включить custom / Let's Encrypt)"
echo " 4) Перезапуск / пересборка контейнеров (+ очистка Docker)"
echo " 11) Миграция БД и пересборка (после git pull)"
echo " 5) Статус сервисов"
echo " 6) Удалить конфиги Nginx IdP"
echo " 7) Сброс PostgreSQL (если ошибка P1000 / пароль БД)"
echo " 8) Очистить Docker (образы/кеш, без пересборки)"
echo " 9) Исправить все ошибки (502, Docker Nginx, порты upstream)"
echo " 0) Выход"
echo ""
local choice
read -r -p "Выберите пункт [0-12]: " choice
case "$choice" in
1) NON_INTERACTIVE=0; action_full_install ;;
10) NON_INTERACTIVE=0; action_fresh_install ;;
2) NON_INTERACTIVE=0; action_configure_domains ;;
3) action_renew_ssl ;;
4) action_restart ;;
11) action_migrate_rebuild ;;
5) show_status ;;
6) remove_nginx_configs; ok "Готово" ;;
7) action_reset_db; action_restart ;;
8) action_docker_prune ;;
9) action_fix_all_errors ;;
0) exit 0 ;;
*) warn "Неверный пункт" ;;
esac
done
}
main() {
parse_args "$@"
preflight_checks
detect_os
install_base_packages
install_docker
check_docker_compose
case "${ACTION:-}" in
install) action_full_install ;;
fresh-install) action_fresh_install ;;
configure-domains) action_configure_domains ;;
renew-ssl) action_renew_ssl ;;
restart) action_restart ;;
migrate-rebuild) action_migrate_rebuild ;;
fix-host-nginx|fix-docker-nginx) action_fix_docker_nginx ;;
docker-prune) action_docker_prune ;;
fix-proxy) action_fix_host_proxy ;;
fix-all) action_fix_all_errors ;;
status) show_status ;;
reset-db) action_reset_db ;;
remove-nginx) remove_nginx_configs ;;
"")
if [[ -f .env ]] && [[ "$NON_INTERACTIVE" -ne 1 ]]; then
show_menu
else
action_full_install
fi
;;
*) fail "Неизвестное действие: $ACTION" ;;
esac
}
main "$@"