#!/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.5.0" 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:-auto}" 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 </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 auto)" != "auto" ]]; then NGINX_MODE="$(env_get NGINX_MODE auto)" fi if [[ "$NGINX_MODE" == "auto" ]]; then if [[ "$(uname -s)" == "Linux" ]] && { [[ -d /etc/nginx ]] || has_cmd nginx; }; then NGINX_MODE="host" else NGINX_MODE="docker" fi fi log "Режим Nginx: ${NGINX_MODE} (host=системный, docker=контейнер)" } 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 } # --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)..." 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" \ || port_in_use "$NGINX_HTTPS_PORT"; then log "Остановка системного Nginx (освобождение портов ${NGINX_HTTP_PORT}/${NGINX_HTTPS_PORT})..." run_root systemctl stop nginx 2>/dev/null || run_root service nginx stop 2>/dev/null || true sleep 2 fi docker_cmd rm -f lendry-id-nginx 2>/dev/null || true env_set NGINX_HTTP_PORT "$NGINX_HTTP_PORT" env_set NGINX_HTTPS_PORT "$NGINX_HTTPS_PORT" 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 } # Не даём контейнерному nginx занять 80/443, если их уже держит системный nginx # или явно выбран NGINX_MODE=host (типичная причина «address already in use»). ensure_docker_nginx_not_conflicting() { load_env if [[ "${FORCE_DOCKER_NGINX:-0}" -eq 1 ]]; then NGINX_MODE="docker" return 0 fi resolve_nginx_mode_and_ports local http="${NGINX_HTTP_PORT:-80}" local https="${NGINX_HTTPS_PORT:-443}" local ports_busy=0 port_in_use "$http" && ports_busy=1 port_in_use "$https" && ports_busy=1 if [[ "$ports_busy" -eq 1 ]] && [[ "$(env_get NGINX_MODE docker)" == "docker" ]]; then if host_nginx_available || system_nginx_is_active || lendry_host_nginx_configured; then warn "Порты ${http}/${https} заняты системным Nginx — переключаем NGINX_MODE на host" warn "Контейнер lendry-id-nginx не будет запущен (используйте системный Nginx)" NGINX_MODE="host" env_set NGINX_MODE "host" else warn "Порты ${http}/${https} заняты другим процессом — Docker Nginx не сможет стартовать" warn "Освободите порты или укажите другие: --nginx-http-port / --nginx-https-port" fi fi if [[ "$(env_get NGINX_MODE docker)" == "host" ]] || [[ "$ports_busy" -eq 1 ]]; then stop_docker_nginx 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 } 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 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 } 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 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 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 system_nginx_is_active || lendry_host_nginx_configured; then if [[ "$NGINX_MODE" == "docker" ]]; then warn "На сервере активен системный Nginx — переключаем NGINX_MODE на host" warn "Docker Nginx (lendry-id-nginx) не совместим с системным Nginx на 443" NGINX_MODE="host" env_set NGINX_MODE "host" fi 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 host_nginx_available || system_nginx_is_active || lendry_host_nginx_configured; then warn "Порт ${NGINX_HTTP_PORT}/${NGINX_HTTPS_PORT} занят — переключаемся на системный Nginx (host)" NGINX_MODE="host" env_set NGINX_MODE "host" elif [[ "$NGINX_HTTP_PORT" == "80" && "$NON_INTERACTIVE" -eq 1 ]]; then warn "Порт 80 занят — 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 (рекомендуется, если nginx уже установлен)" 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) NGINX_MODE="host" env_set NGINX_MODE "host" ;; 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-mode host" ;; 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" == "" ]] && 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' >.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" 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" </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 } persist_domain_env() { write_env_secrets_if_missing env_set INSTALL_MODE "${INSTALL_MODE:-local}" env_set NGINX_MODE "${NGINX_MODE:-auto}" 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 <"$COMPOSE_OVERRIDE" </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_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 300s; 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 </.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" </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 auto)" == "docker" ]] \ || docker_nginx_container_running; then NGINX_MODE="docker" env_set NGINX_MODE "docker" elif [[ -n "$(env_get NGINX_MODE "")" && "$(env_get NGINX_MODE auto)" != "auto" ]]; then NGINX_MODE="$(env_get NGINX_MODE auto)" else detect_nginx_mode 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 || return 1 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: проверка с хоста (как curl из браузера / DevTools) if [[ "$(env_get USE_NGINX_SSL false)" == "true" || "$ssl_type" != "none" ]]; then code="$(curl -sk --max-time 6 -o /dev/null -w '%{http_code}' "https://${frontend_domain}/idp-api/health" 2>/dev/null || echo 000)" else code="$(curl -fsS --max-time 6 -o /dev/null -w '%{http_code}' "http://${frontend_domain}/idp-api/health" 2>/dev/null || echo 000)" fi if [[ "$code" == "200" ]]; then ok "SSO /idp-api/health → 200 (проверка с хоста; внутри контейнера — см. SSL/301)" return 0 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" || return 1 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 if [[ "$(env_get USE_NGINX_SSL false)" == "true" || "$ssl_type" != "none" ]]; then code="$(curl -sk --max-time 8 -o /dev/null -w '%{http_code}' "https://${frontend_domain}/" 2>/dev/null || echo 000)" else code="$(curl -fsS --max-time 8 -o /dev/null -w '%{http_code}' "http://${frontend_domain}/" 2>/dev/null || echo 000)" fi if [[ "$code" == "200" || "$code" == "304" ]]; then ok "SSO / → HTTP ${code} (проверка с хоста)" return 0 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 -sk --max-time 8 -o /dev/null -w '%{http_code}' "https://${frontend_domain}/" 2>/dev/null || echo 000)" else code="$(curl -fsS --max-time 8 -o /dev/null -w '%{http_code}' "http://${frontend_domain}/" 2>/dev/null || echo 000)" 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 -fsS --max-time 5 -o /dev/null -w '%{http_code}' "http://${frontend_domain}/idp-api/health" 2>/dev/null || echo 000)" else code="$(curl -sk --max-time 5 -o /dev/null -w '%{http_code}' "https://${frontend_domain}/idp-api/health" 2>/dev/null || echo 000)" 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}" 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 "Сборка и запуск контейнеров (10–20 минут при первом запуске)..." 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" </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}" "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}" "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" \ "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= ./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, но запущен lendry-id-nginx" echo " Решение: ./install.sh --fix-all" 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 if grep -q 'proxy_pass.*\$request_uri' "${LOCAL_CONF_DIR}/${NGINX_PREFIX}-"*.conf 2>/dev/null; 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)) fi shopt -u nullglob 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 auto)" 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 "КОНФЛИКТ: контейнер lendry-id-nginx запущен при NGINX_MODE=host — выполните: ./install.sh --fix-all" else echo " Docker Nginx: запущен (режим docker; curl 127.0.0.1:3002 не нужен)" fi 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/6: диагностика..." diagnose_common_issues || true log "Шаг 2/6: Docker Nginx — остановка system nginx, освобождение 80/443..." ensure_docker_nginx_mode_for_fix_all write_compose_override ensure_split_domain_public_urls log "Шаг 3/6: пересборка 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 "Шаг 4/6: пересборка 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 "Шаг 5/6: 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 "Шаг 6/6: финальная проверка (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 " 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-10]: " 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 ;; 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 ;; 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 "$@"