4031 lines
149 KiB
Bash
4031 lines
149 KiB
Bash
#!/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 <<EOF
|
||
${BOLD}Lendry ID v${SCRIPT_VERSION}${NC} — установка и управление
|
||
|
||
./install.sh Интерактивное меню
|
||
./install.sh --install Полная установка
|
||
./install.sh --fresh-install Полная очистка Docker + установка с нуля
|
||
./install.sh --configure-domains Изменить домены / Nginx / SSL
|
||
./install.sh --renew-ssl Обновить сертификаты (Let's Encrypt / custom / selfsigned)
|
||
./install.sh --restart Перезапуск контейнеров
|
||
./install.sh --docker-prune Только очистка Docker (без пересборки)
|
||
./install.sh --fix-proxy Исправить 502: host Nginx + порты 3000/3002/3003
|
||
./install.sh --fix-all Комплексное исправление: 502, прокси, порты, Nginx
|
||
./install.sh --status Статус сервисов
|
||
./install.sh --reset-db Сброс PostgreSQL (новый пароль из .env, данные удалятся)
|
||
./install.sh --remove-nginx Удалить конфиги Nginx IdP
|
||
|
||
${BOLD}Режим локальной сети (без интернета / AD-домены .local .lpr):${NC}
|
||
--intranet Локальная сеть: HTTP или самоподписанный HTTPS
|
||
--ssl TYPE none | selfsigned | custom | letsencrypt
|
||
--cert-dir PATH Папка со своими сертификатами (для --ssl custom)
|
||
--cert-fullchain FILE Файл сертификата (fullchain.pem, _wildcard.domain.pem, …)
|
||
--cert-key FILE Файл ключа (privkey.pem, _wildcard.domain-key.pem, …)
|
||
--nginx-http-port PORT HTTP-порт Nginx (если 80 занят, например 8080)
|
||
--nginx-https-port PORT HTTPS-порт Nginx (если 443 занят, например 8443)
|
||
--offline Не скачивать Docker из интернета (только если уже установлен)
|
||
|
||
${BOLD}Домены (можно указывать отдельно):${NC}
|
||
--api-domain DOMAIN API / Swagger
|
||
--frontend-domain DOMAIN Веб-интерфейс
|
||
--docs-domain DOMAIN Документация
|
||
--ws-domain DOMAIN WebSocket (пусто = API + /ws)
|
||
--minio-domain DOMAIN MinIO S3 (опционально)
|
||
--minio-console-domain DOMAIN MinIO Console (опционально)
|
||
--email EMAIL Email для Let's Encrypt (только ssl=letsencrypt)
|
||
|
||
${BOLD}Прочие опции:${NC}
|
||
-y, --yes Без интерактивных вопросов
|
||
--local localhost:3000/3002/3003 без Nginx
|
||
-h, --help Справка
|
||
|
||
${BOLD}Переменные окружения:${NC}
|
||
FORCE_ENV=1 Новые JWT-секреты (пароли БД/MinIO/RabbitMQ сохраняются если volume уже есть)
|
||
OFFLINE=1 То же, что --offline
|
||
FORCE_ENV=1 При --fresh-install: удалить .env и секреты, создать заново
|
||
SKIP_DOCKER_PRUNE=1 Не очищать образы/кеш перед пересборкой
|
||
DOCKER_PRUNE_AGGRESSIVE=1 Глубже чистить неиспользуемые образы проекта
|
||
COMPOSE_PARALLEL_LIMIT=4 Параллельная сборка сервисов (по умолчанию 4)
|
||
|
||
${BOLD}Примеры:${NC}
|
||
./install.sh --intranet --install --yes \\
|
||
--api-domain api.idpmvk.lpr \\
|
||
--frontend-domain id.idpmvk.lpr \\
|
||
--docs-domain docs.idpmvk.lpr \\
|
||
--ssl custom --cert-dir /opt/idp-certs
|
||
|
||
./install.sh --intranet --install --yes \\
|
||
--api-domain api.idpmvk.lpr \\
|
||
--frontend-domain id.idpmvk.lpr \\
|
||
--docs-domain docs.idpmvk.lpr \\
|
||
--ssl none
|
||
|
||
./install.sh --intranet --ssl selfsigned \\
|
||
--api-domain api.idpmvk.lpr --frontend-domain id.idpmvk.lpr \\
|
||
--docs-domain docs.idpmvk.lpr
|
||
|
||
./install.sh --install --yes \\
|
||
--api-domain api.id.example.ru \\
|
||
--frontend-domain id.example.ru \\
|
||
--docs-domain docs.example.ru \\
|
||
--ssl letsencrypt --email admin@example.ru
|
||
./install.sh --configure-domains
|
||
|
||
${BOLD}Полная переустановка (очистка Docker + volumes + установка):${NC}
|
||
./install.sh --fresh-install --yes \\
|
||
--intranet \\
|
||
--api-domain api.idpmvk.lpr \\
|
||
--frontend-domain sso.idpmvk.lpr \\
|
||
--docs-domain docs.idpmvk.lpr \\
|
||
--ssl selfsigned
|
||
EOF
|
||
}
|
||
|
||
parse_args() {
|
||
while [[ $# -gt 0 ]]; do
|
||
case "$1" in
|
||
-y | --yes) NON_INTERACTIVE=1; shift ;;
|
||
--install) ACTION="install"; shift ;;
|
||
--fresh-install) ACTION="fresh-install"; shift ;;
|
||
--configure-domains) ACTION="configure-domains"; shift ;;
|
||
--renew-ssl) ACTION="renew-ssl"; shift ;;
|
||
--restart) ACTION="restart"; shift ;;
|
||
--docker-prune) ACTION="docker-prune"; shift ;;
|
||
--skip-docker-prune) SKIP_DOCKER_PRUNE=1; shift ;;
|
||
--docker-prune-aggressive) DOCKER_PRUNE_AGGRESSIVE=1; shift ;;
|
||
--fix-proxy) ACTION="fix-proxy"; shift ;;
|
||
--fix-all) ACTION="fix-all"; shift ;;
|
||
--status) ACTION="status"; shift ;;
|
||
--reset-db) ACTION="reset-db"; shift ;;
|
||
--local) INSTALL_LOCAL=1; shift ;;
|
||
--intranet) INSTALL_INTRANET=1; shift ;;
|
||
--offline) OFFLINE=1; shift ;;
|
||
--ssl) CLI_SSL_TYPE="${2:-}"; shift 2 ;;
|
||
--cert-dir) CLI_CERT_DIR="${2:-}"; shift 2 ;;
|
||
--cert-fullchain) CLI_CERT_FULLCHAIN="${2:-}"; shift 2 ;;
|
||
--cert-key) CLI_CERT_KEY="${2:-}"; shift 2 ;;
|
||
--nginx-mode) NGINX_MODE="${2:-auto}"; NGINX_MODE_CLI="${2:-auto}"; shift 2 ;;
|
||
--nginx-http-port) CLI_NGINX_HTTP_PORT="${2:-}"; shift 2 ;;
|
||
--nginx-https-port) CLI_NGINX_HTTPS_PORT="${2:-}"; shift 2 ;;
|
||
--api-domain) CLI_DOMAIN_API="${2:-}"; shift 2 ;;
|
||
--frontend-domain) CLI_DOMAIN_FRONTEND="${2:-}"; shift 2 ;;
|
||
--docs-domain) CLI_DOMAIN_DOCS="${2:-}"; shift 2 ;;
|
||
--ws-domain) CLI_DOMAIN_WS="${2:-}"; shift 2 ;;
|
||
--minio-domain) CLI_DOMAIN_MINIO="${2:-}"; shift 2 ;;
|
||
--minio-console-domain) CLI_DOMAIN_MINIO_CONSOLE="${2:-}"; shift 2 ;;
|
||
--email) CLI_EMAIL="${2:-}"; shift 2 ;;
|
||
-h | --help) usage; exit 0 ;;
|
||
*) fail "Неизвестный аргумент: $1 (используйте --help)" ;;
|
||
esac
|
||
done
|
||
}
|
||
|
||
has_cmd() { command -v "$1" >/dev/null 2>&1; }
|
||
|
||
detect_os() {
|
||
if [[ -f /etc/os-release ]]; then
|
||
# shellcheck disable=SC1091
|
||
. /etc/os-release
|
||
OS_ID="${ID:-unknown}"
|
||
OS_VERSION_ID="${VERSION_ID:-}"
|
||
else
|
||
OS_ID="unknown"
|
||
fi
|
||
|
||
case "$OS_ID" in
|
||
ubuntu | debian | linuxmint | pop) PKG_MANAGER="apt" ;;
|
||
fedora | rhel | centos | rocky | almalinux) PKG_MANAGER="dnf" ;;
|
||
opensuse* | sles) PKG_MANAGER="zypper" ;;
|
||
*) PKG_MANAGER="unknown" ;;
|
||
esac
|
||
|
||
log "ОС: ${OS_ID:-unknown} ${OS_VERSION_ID:-} (пакеты: $PKG_MANAGER)"
|
||
}
|
||
|
||
run_root() {
|
||
if [[ "$(id -u)" -eq 0 ]]; then
|
||
"$@"
|
||
elif has_cmd sudo; then
|
||
sudo "$@"
|
||
else
|
||
fail "Нужны права root/sudo для: $*"
|
||
fi
|
||
}
|
||
|
||
preflight_checks() {
|
||
if [[ "$(uname -s)" != "Linux" ]]; then
|
||
warn "ОС не Linux — Nginx будет запущен в Docker (профиль proxy). Убедитесь, что Docker Desktop установлен."
|
||
NGINX_MODE="docker"
|
||
fi
|
||
[[ -f docker-compose.yml ]] || fail "Запустите скрипт из корня репозитория (рядом с docker-compose.yml)"
|
||
mkdir -p "$LOCAL_CONF_DIR" "$LOCAL_CERTS_DIR" "$DEFAULT_CERT_UPLOAD_DIR"
|
||
}
|
||
|
||
detect_nginx_mode() {
|
||
load_env
|
||
if [[ -n "$(env_get NGINX_MODE "")" && "$(env_get NGINX_MODE 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" == "<none>" ]] && continue
|
||
case "$repo" in
|
||
lendry-id-* | idservice-* | "${project}"-*)
|
||
if ! docker_cmd ps -a --filter "ancestor=${id}" -q 2>/dev/null | grep -q .; then
|
||
docker_cmd rmi -f "$id" 2>/dev/null || true
|
||
fi
|
||
;;
|
||
esac
|
||
done < <(docker_cmd images --format '{{.Repository}} {{.ID}}' 2>/dev/null || true)
|
||
fi
|
||
}
|
||
|
||
docker_prune_before_rebuild() {
|
||
local aggressive="${1:-0}"
|
||
|
||
log "Очистка Docker перед пересборкой (volumes данных не удаляются)..."
|
||
build_compose_stack_cmd
|
||
|
||
if [[ "$aggressive" == "1" ]]; then
|
||
log " Режим aggressive: остановка стека и удаление orphan-контейнеров..."
|
||
docker_cmd "${COMPOSE_STACK_CMD[@]}" down --remove-orphans 2>/dev/null || true
|
||
else
|
||
log " Удаление остановленных контейнеров проекта..."
|
||
docker_remove_stopped_project_containers
|
||
docker_cmd container prune -f --filter "until=24h" 2>/dev/null || true
|
||
fi
|
||
|
||
log " Удаление dangling-образов и старого build cache..."
|
||
docker_cmd image prune -f 2>/dev/null || true
|
||
if docker_cmd builder prune --help 2>/dev/null | grep -q 'until='; then
|
||
docker_cmd builder prune -f --filter "until=168h" 2>/dev/null || \
|
||
docker_cmd builder prune -f 2>/dev/null || true
|
||
else
|
||
docker_cmd builder prune -f 2>/dev/null || true
|
||
fi
|
||
|
||
log " Удаление неиспользуемых образов проекта..."
|
||
docker_prune_stale_project_images "$(compose_project_name)" "$aggressive"
|
||
|
||
ok "Docker очищен перед сборкой"
|
||
docker_disk_usage_brief
|
||
}
|
||
|
||
docker_prune_after_rebuild() {
|
||
docker_cmd image prune -f 2>/dev/null || true
|
||
}
|
||
|
||
compose_build_parallel_flags() {
|
||
COMPOSE_BUILD_PARALLEL=()
|
||
if docker_cmd compose build --help 2>/dev/null | grep -q -- '--parallel'; then
|
||
COMPOSE_BUILD_PARALLEL=(--parallel)
|
||
fi
|
||
}
|
||
|
||
compose_build_and_up() {
|
||
local -a services=()
|
||
if [[ $# -gt 0 ]]; then
|
||
services=("$@")
|
||
fi
|
||
|
||
configure_docker_build_env
|
||
ensure_docker_nginx_not_conflicting
|
||
build_compose_stack_cmd
|
||
compose_build_parallel_flags
|
||
|
||
if [[ ${#services[@]} -gt 0 ]]; then
|
||
local -a filtered=()
|
||
local svc
|
||
for svc in "${services[@]}"; do
|
||
if [[ "$svc" == "nginx" ]] && ! compose_uses_proxy_profile; then
|
||
warn "Пропуск сервиса nginx: порты заняты или NGINX_MODE=host — используйте системный Nginx"
|
||
continue
|
||
fi
|
||
filtered+=("$svc")
|
||
done
|
||
services=("${filtered[@]}")
|
||
if [[ ${#services[@]} -eq 0 ]]; then
|
||
warn "Нет сервисов для запуска после проверки конфликта Nginx"
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
if [[ "${SKIP_DOCKER_PRUNE:-0}" -ne 1 ]]; then
|
||
docker_prune_before_rebuild "${DOCKER_PRUNE_AGGRESSIVE:-0}"
|
||
fi
|
||
|
||
if [[ ${#services[@]} -gt 0 ]]; then
|
||
log "Сборка сервисов: ${services[*]} (BuildKit, limit=${COMPOSE_PARALLEL_LIMIT})..."
|
||
docker_cmd "${COMPOSE_STACK_CMD[@]}" build "${COMPOSE_BUILD_PARALLEL[@]}" "${services[@]}"
|
||
log "Запуск сервисов..."
|
||
docker_cmd "${COMPOSE_STACK_CMD[@]}" up -d --remove-orphans "${services[@]}"
|
||
else
|
||
log "Сборка образов (BuildKit, параллельно, limit=${COMPOSE_PARALLEL_LIMIT})..."
|
||
docker_cmd "${COMPOSE_STACK_CMD[@]}" build "${COMPOSE_BUILD_PARALLEL[@]}"
|
||
log "Запуск контейнеров..."
|
||
docker_cmd "${COMPOSE_STACK_CMD[@]}" up -d --remove-orphans
|
||
fi
|
||
|
||
ensure_docker_nginx_not_conflicting
|
||
|
||
if [[ "${SKIP_DOCKER_PRUNE:-0}" -ne 1 ]]; then
|
||
docker_prune_after_rebuild
|
||
fi
|
||
|
||
local svc
|
||
for svc in "${services[@]}"; do
|
||
case "$svc" in
|
||
api-gateway) sync_nginx_after_api_gateway_restart ;;
|
||
frontend|docs)
|
||
wait_for_service "$svc" 90
|
||
sync_nginx_after_edge_restart
|
||
;;
|
||
media-ws)
|
||
wait_for_service "media-ws" 60
|
||
reload_docker_nginx
|
||
;;
|
||
esac
|
||
done
|
||
}
|
||
|
||
action_docker_prune() {
|
||
load_env
|
||
prepare_compose_stack
|
||
SKIP_DOCKER_PRUNE=0
|
||
docker_prune_before_rebuild "${DOCKER_PRUNE_AGGRESSIVE:-1}"
|
||
ok "Очистка Docker завершена"
|
||
}
|
||
|
||
install_nginx_host() {
|
||
if has_cmd nginx; then
|
||
ok "Nginx уже установлен"
|
||
return
|
||
fi
|
||
|
||
log "Установка Nginx..."
|
||
case "$PKG_MANAGER" in
|
||
apt) run_root apt-get install -y -qq nginx ;;
|
||
dnf) run_root dnf install -y nginx ;;
|
||
*) fail "Автоустановка Nginx для $OS_ID не поддерживается. Установите nginx вручную или используйте --nginx-mode docker" ;;
|
||
esac
|
||
|
||
run_root systemctl enable nginx >/dev/null 2>&1 || true
|
||
ok "Nginx установлен"
|
||
}
|
||
|
||
install_certbot() {
|
||
if has_cmd certbot; then
|
||
ok "Certbot уже установлен"
|
||
return
|
||
fi
|
||
|
||
if [[ "$OFFLINE" -eq 1 || "$INSTALL_INTRANET" -eq 1 ]]; then
|
||
fail "Certbot недоступен offline. Для локальной сети используйте --ssl none или --ssl selfsigned"
|
||
fi
|
||
|
||
log "Установка Certbot..."
|
||
case "$PKG_MANAGER" in
|
||
apt) run_root apt-get install -y -qq certbot python3-certbot-nginx ;;
|
||
dnf) run_root dnf install -y certbot ;;
|
||
*) fail "Автоустановка Certbot для $OS_ID не поддерживается" ;;
|
||
esac
|
||
|
||
run_root mkdir -p "$CERTBOT_WEBROOT"
|
||
ok "Certbot установлен"
|
||
}
|
||
|
||
install_nginx_certbot() {
|
||
if [[ "$NGINX_MODE" == "docker" ]]; then
|
||
ok "Nginx в Docker — установка системного nginx не требуется"
|
||
return
|
||
fi
|
||
install_nginx_host
|
||
}
|
||
|
||
setup_firewall() {
|
||
if ! has_cmd ufw; then
|
||
warn "UFW не найден — пропускаем настройку фаервола"
|
||
return
|
||
fi
|
||
|
||
if ! run_root ufw status 2>/dev/null | grep -q "Status: active"; then
|
||
warn "UFW не активен — откройте порты 80 и 443 вручную при необходимости"
|
||
return
|
||
fi
|
||
|
||
log "Настройка UFW (80, 443, SSH)..."
|
||
run_root ufw allow 22/tcp >/dev/null 2>&1 || true
|
||
run_root ufw allow 80/tcp >/dev/null 2>&1 || true
|
||
run_root ufw allow 443/tcp >/dev/null 2>&1 || true
|
||
ok "Порты 80 и 443 разрешены в UFW"
|
||
}
|
||
|
||
rand_secret() {
|
||
local length="${1:-48}"
|
||
if has_cmd openssl; then
|
||
openssl rand -base64 64 | tr -dc 'A-Za-z0-9' | head -c "$length"
|
||
else
|
||
tr -dc 'A-Za-z0-9' </dev/urandom | head -c "$length"
|
||
fi
|
||
}
|
||
|
||
prompt() {
|
||
local var_name="$1"
|
||
local question="$2"
|
||
local default="$3"
|
||
local value
|
||
|
||
if [[ "$NON_INTERACTIVE" -eq 1 ]]; then
|
||
printf -v "$var_name" '%s' "$default"
|
||
return
|
||
fi
|
||
|
||
read -r -p "$question [$default]: " value
|
||
if [[ -z "$value" ]]; then
|
||
value="$default"
|
||
fi
|
||
printf -v "$var_name" '%s' "$value"
|
||
}
|
||
|
||
prompt_optional() {
|
||
local var_name="$1"
|
||
local question="$2"
|
||
local default="$3"
|
||
local value
|
||
|
||
if [[ "$NON_INTERACTIVE" -eq 1 ]]; then
|
||
printf -v "$var_name" '%s' "$default"
|
||
return
|
||
fi
|
||
|
||
if [[ -n "$default" ]]; then
|
||
read -r -p "$question [$default, Enter=пропустить]: " value
|
||
if [[ -z "$value" ]]; then
|
||
value="$default"
|
||
fi
|
||
else
|
||
read -r -p "$question [Enter=пропустить]: " value
|
||
fi
|
||
printf -v "$var_name" '%s' "$value"
|
||
}
|
||
|
||
validate_domain() {
|
||
local domain="$1"
|
||
[[ -z "$domain" ]] && return 0
|
||
[[ "$domain" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$ ]]
|
||
}
|
||
|
||
normalize_domain() {
|
||
local d="${1:-}"
|
||
d="${d#https://}"
|
||
d="${d#http://}"
|
||
d="${d#wss://}"
|
||
d="${d#ws://}"
|
||
d="${d%%/*}"
|
||
d="${d%%:*}"
|
||
echo "$d"
|
||
}
|
||
|
||
load_env() {
|
||
if [[ -f .env ]]; then
|
||
# shellcheck disable=SC1091
|
||
set -a && source .env && set +a
|
||
fi
|
||
NGINX_MODE="${NGINX_MODE:-auto}"
|
||
}
|
||
|
||
env_get() {
|
||
local key="$1"
|
||
local default="${2:-}"
|
||
if [[ -f .env ]] && grep -q "^${key}=" .env; then
|
||
grep "^${key}=" .env | head -n1 | cut -d= -f2-
|
||
else
|
||
echo "$default"
|
||
fi
|
||
}
|
||
|
||
env_set() {
|
||
local key="$1"
|
||
local value="$2"
|
||
local escaped
|
||
escaped="$(printf '%s' "$value" | sed 's/[&/\]/\\&/g')"
|
||
|
||
if [[ -f .env ]] && grep -q "^${key}=" .env; then
|
||
sed -i "s|^${key}=.*|${key}=${escaped}|" .env
|
||
elif [[ -f .env ]]; then
|
||
printf '%s=%s\n' "$key" "$value" >>.env
|
||
else
|
||
printf '%s=%s\n' "$key" "$value" >.env
|
||
fi
|
||
chmod 600 .env 2>/dev/null || true
|
||
}
|
||
|
||
derive_public_urls() {
|
||
local ssl_type="${1:-none}"
|
||
|
||
if [[ "$INSTALL_LOCAL" -eq 1 ]]; then
|
||
if [[ -z "${DOMAIN_API:-}" && -z "${DOMAIN_FRONTEND:-}" ]]; then
|
||
PUBLIC_API_URL="http://localhost:3000"
|
||
PUBLIC_FRONTEND_URL="http://localhost:3002"
|
||
PUBLIC_DOCS_URL="http://localhost:3003"
|
||
PUBLIC_WS_URL="ws://localhost:8085/ws"
|
||
MINIO_PUBLIC_ENDPOINT="${MINIO_PUBLIC_ENDPOINT:-localhost:9000}"
|
||
MINIO_USE_SSL="false"
|
||
USE_NGINX_SSL="false"
|
||
SSL_TYPE="none"
|
||
INSTALL_MODE="local"
|
||
return
|
||
fi
|
||
fi
|
||
|
||
local scheme="http"
|
||
[[ "$ssl_type" == "letsencrypt" || "$ssl_type" == "selfsigned" || "$ssl_type" == "custom" ]] && scheme="https"
|
||
|
||
PUBLIC_API_URL="${scheme}://${DOMAIN_API}"
|
||
PUBLIC_FRONTEND_URL="${scheme}://${DOMAIN_FRONTEND}"
|
||
PUBLIC_DOCS_URL="${scheme}://${DOMAIN_DOCS}"
|
||
|
||
if [[ -n "${DOMAIN_WS:-}" && "${DOMAIN_WS}" != "${DOMAIN_API}" ]]; then
|
||
if [[ "$scheme" == "https" ]]; then
|
||
PUBLIC_WS_URL="wss://${DOMAIN_WS}/ws"
|
||
else
|
||
PUBLIC_WS_URL="ws://${DOMAIN_WS}/ws"
|
||
fi
|
||
else
|
||
if [[ "$scheme" == "https" ]]; then
|
||
PUBLIC_WS_URL="wss://${DOMAIN_API}/ws"
|
||
else
|
||
PUBLIC_WS_URL="ws://${DOMAIN_API}/ws"
|
||
fi
|
||
fi
|
||
|
||
if [[ -n "${DOMAIN_MINIO:-}" ]]; then
|
||
MINIO_PUBLIC_ENDPOINT="${DOMAIN_MINIO}"
|
||
MINIO_USE_SSL=$([[ "$scheme" == "https" ]] && echo true || echo false)
|
||
fi
|
||
|
||
if [[ "$ssl_type" == "selfsigned" && -n "${DOMAIN_FRONTEND:-}" ]]; then
|
||
if [[ -z "${DOMAIN_API:-}" || "${DOMAIN_API}" == "${DOMAIN_FRONTEND}" ]]; then
|
||
apply_same_origin_api_proxy_urls "$scheme"
|
||
fi
|
||
fi
|
||
|
||
if [[ "$ssl_type" == "letsencrypt" || "$ssl_type" == "selfsigned" || "$ssl_type" == "custom" ]]; then
|
||
env_set FEDCM_COOKIE_SECURE "true"
|
||
fi
|
||
|
||
SSL_TYPE="$ssl_type"
|
||
USE_NGINX_SSL=$([[ "$ssl_type" == "none" ]] && echo false || echo true)
|
||
|
||
if [[ "$INSTALL_LOCAL" -eq 1 ]]; then
|
||
INSTALL_MODE="local"
|
||
elif [[ "$INSTALL_INTRANET" -eq 1 || "$ssl_type" == "none" || "$ssl_type" == "selfsigned" || "$ssl_type" == "custom" ]]; then
|
||
INSTALL_MODE="intranet"
|
||
elif [[ -n "${DOMAIN_API:-}" ]]; then
|
||
INSTALL_MODE="production"
|
||
else
|
||
INSTALL_MODE="local"
|
||
fi
|
||
}
|
||
|
||
apply_same_origin_api_proxy_urls() {
|
||
local scheme="${1:-https}"
|
||
PUBLIC_API_URL="${PUBLIC_FRONTEND_URL%/}/idp-api"
|
||
if [[ -z "${DOMAIN_WS:-}" || "${DOMAIN_WS}" == "${DOMAIN_API}" ]]; then
|
||
if [[ "$scheme" == "https" ]]; then
|
||
PUBLIC_WS_URL="wss://${DOMAIN_FRONTEND}/idp-api/ws"
|
||
else
|
||
PUBLIC_WS_URL="ws://${DOMAIN_FRONTEND}/idp-api/ws"
|
||
fi
|
||
fi
|
||
}
|
||
|
||
# Split-domain (api.xxx + sso.xxx): PUBLIC_API_URL должен быть https://api.xxx,
|
||
# а не https://sso.xxx/idp-api — иначе фронтенд шлёт запросы не туда.
|
||
ensure_split_domain_public_urls() {
|
||
load_env
|
||
local api_domain frontend_domain public_api ssl_type scheme ws_url
|
||
api_domain="$(env_get DOMAIN_API "")"
|
||
frontend_domain="$(env_get DOMAIN_FRONTEND "")"
|
||
public_api="$(env_get PUBLIC_API_URL "")"
|
||
ssl_type="$(env_get SSL_TYPE none)"
|
||
|
||
[[ -n "$api_domain" && -n "$frontend_domain" ]] || return 0
|
||
[[ "$api_domain" != "$frontend_domain" ]] || return 0
|
||
|
||
if [[ "$public_api" != *"/idp-api"* ]]; then
|
||
return 0
|
||
fi
|
||
|
||
scheme="http"
|
||
[[ "$ssl_type" == "letsencrypt" || "$ssl_type" == "selfsigned" || "$ssl_type" == "custom" ]] && scheme="https"
|
||
|
||
warn "PUBLIC_API_URL=${public_api} — некорректно для split-domain (API и SSO на разных доменах)"
|
||
PUBLIC_API_URL="${scheme}://${api_domain}"
|
||
env_set PUBLIC_API_URL "${PUBLIC_API_URL}"
|
||
|
||
ws_url="$(env_get PUBLIC_WS_URL "")"
|
||
if [[ -z "$ws_url" || "$ws_url" == *"/idp-api"* ]]; then
|
||
if [[ "$scheme" == "https" ]]; then
|
||
PUBLIC_WS_URL="wss://${api_domain}/ws"
|
||
else
|
||
PUBLIC_WS_URL="ws://${api_domain}/ws"
|
||
fi
|
||
env_set PUBLIC_WS_URL "${PUBLIC_WS_URL}"
|
||
fi
|
||
|
||
env_set INTERNAL_API_URL "http://api-gateway:3000"
|
||
ok "Исправлено: PUBLIC_API_URL=${PUBLIC_API_URL}, PUBLIC_WS_URL=$(env_get PUBLIC_WS_URL "")"
|
||
}
|
||
|
||
choose_ssl_type_interactive() {
|
||
local default="${1:-none}"
|
||
|
||
if [[ -n "$CLI_SSL_TYPE" ]]; then
|
||
SSL_TYPE="$CLI_SSL_TYPE"
|
||
return
|
||
fi
|
||
|
||
if [[ "$NON_INTERACTIVE" -eq 1 ]]; then
|
||
if [[ "$INSTALL_INTRANET" -eq 1 ]]; then
|
||
SSL_TYPE="${default:-none}"
|
||
else
|
||
SSL_TYPE="${default:-letsencrypt}"
|
||
fi
|
||
return
|
||
fi
|
||
|
||
echo ""
|
||
echo -e "${BOLD}Тип SSL / доступ:${NC}"
|
||
echo " 1) HTTP — локальная сеть, без сертификатов (http://api.idpmvk.lpr)"
|
||
echo " 2) HTTPS самоподписанный — локальная сеть, браузер покажет предупреждение"
|
||
echo " 3) HTTPS Let's Encrypt — нужен интернет и публичный DNS"
|
||
echo " 4) HTTPS свои сертификаты — загрузите fullchain.pem и privkey.pem в папку на сервере"
|
||
echo ""
|
||
|
||
local ssl_choice
|
||
if [[ "$INSTALL_INTRANET" -eq 1 ]]; then
|
||
read -r -p "Выберите [1-4] [1]: " ssl_choice
|
||
ssl_choice="${ssl_choice:-1}"
|
||
else
|
||
read -r -p "Выберите [1-4] [3]: " ssl_choice
|
||
ssl_choice="${ssl_choice:-3}"
|
||
fi
|
||
|
||
case "$ssl_choice" in
|
||
1) SSL_TYPE="none" ;;
|
||
2) SSL_TYPE="selfsigned" ;;
|
||
3) SSL_TYPE="letsencrypt" ;;
|
||
4) SSL_TYPE="custom" ;;
|
||
*) SSL_TYPE="$default" ;;
|
||
esac
|
||
}
|
||
|
||
configure_domains_interactive() {
|
||
local existing_api existing_frontend existing_docs existing_ws existing_email
|
||
if [[ "$(env_get INSTALL_MODE "")" == "intranet" ]]; then
|
||
INSTALL_INTRANET=1
|
||
fi
|
||
existing_api="$(normalize_domain "$(env_get DOMAIN_API "")")"
|
||
existing_frontend="$(normalize_domain "$(env_get DOMAIN_FRONTEND "")")"
|
||
existing_docs="$(normalize_domain "$(env_get DOMAIN_DOCS "")")"
|
||
existing_ws="$(normalize_domain "$(env_get DOMAIN_WS "")")"
|
||
existing_email="$(env_get CERTBOT_EMAIL "")"
|
||
|
||
local default_api="${CLI_DOMAIN_API:-$existing_api}"
|
||
local default_frontend="${CLI_DOMAIN_FRONTEND:-$existing_frontend}"
|
||
local default_docs="${CLI_DOMAIN_DOCS:-$existing_docs}"
|
||
local default_ws="${CLI_DOMAIN_WS:-$existing_ws}"
|
||
local default_minio="${CLI_DOMAIN_MINIO:-$(env_get DOMAIN_MINIO "")}"
|
||
local default_minio_console="${CLI_DOMAIN_MINIO_CONSOLE:-$(env_get DOMAIN_MINIO_CONSOLE "")}"
|
||
local default_email="${CLI_EMAIL:-$existing_email}"
|
||
|
||
if [[ "$INSTALL_LOCAL" -eq 1 ]]; then
|
||
DOMAIN_API=""
|
||
DOMAIN_FRONTEND=""
|
||
DOMAIN_DOCS=""
|
||
DOMAIN_WS=""
|
||
DOMAIN_MINIO=""
|
||
DOMAIN_MINIO_CONSOLE=""
|
||
derive_public_urls "none"
|
||
return
|
||
fi
|
||
|
||
echo ""
|
||
echo -e "${BOLD}Настройка доменов${NC}"
|
||
if [[ "$INSTALL_INTRANET" -eq 1 ]]; then
|
||
echo "Локальная сеть: укажите внутренние DNS-имена (например idpmvk.lpr)."
|
||
echo "Пропишите A-записи во внутреннем DNS или файл hosts на клиентах."
|
||
echo "Доступ из интернета и Let's Encrypt не требуются."
|
||
else
|
||
echo "Укажите домен для каждого сервиса (A-запись → IP сервера)."
|
||
fi
|
||
echo "Enter — оставить текущее значение. localhost без Nginx: ./install.sh --local"
|
||
echo ""
|
||
|
||
if [[ "$INSTALL_INTRANET" -eq 1 ]]; then
|
||
if [[ -z "$default_api" ]]; then default_api="api.idpmvk.lpr"; fi
|
||
if [[ -z "$default_frontend" ]]; then default_frontend="id.idpmvk.lpr"; fi
|
||
if [[ -z "$default_docs" ]]; then default_docs="docs.idpmvk.lpr"; fi
|
||
else
|
||
if [[ -z "$default_api" ]]; then default_api="api.example.com"; fi
|
||
if [[ -z "$default_frontend" ]]; then default_frontend="id.example.com"; fi
|
||
if [[ -z "$default_docs" ]]; then default_docs="docs.example.com"; fi
|
||
fi
|
||
|
||
prompt DOMAIN_API "Домен API / Swagger" "$default_api"
|
||
prompt DOMAIN_FRONTEND "Домен Frontend (UI)" "$default_frontend"
|
||
prompt DOMAIN_DOCS "Домен документации" "$default_docs"
|
||
prompt_optional DOMAIN_WS "Домен WebSocket (пусто = ${DOMAIN_API}/ws)" "$default_ws"
|
||
prompt_optional DOMAIN_MINIO "Домен MinIO S3 (опционально)" "$default_minio"
|
||
prompt_optional DOMAIN_MINIO_CONSOLE "Домен MinIO Console (опционально)" "$default_minio_console"
|
||
|
||
DOMAIN_API="$(normalize_domain "$DOMAIN_API")"
|
||
DOMAIN_FRONTEND="$(normalize_domain "$DOMAIN_FRONTEND")"
|
||
DOMAIN_DOCS="$(normalize_domain "$DOMAIN_DOCS")"
|
||
DOMAIN_WS="$(normalize_domain "$DOMAIN_WS")"
|
||
DOMAIN_MINIO="$(normalize_domain "$DOMAIN_MINIO")"
|
||
DOMAIN_MINIO_CONSOLE="$(normalize_domain "$DOMAIN_MINIO_CONSOLE")"
|
||
|
||
for d in "$DOMAIN_API" "$DOMAIN_FRONTEND" "$DOMAIN_DOCS" "$DOMAIN_WS" "$DOMAIN_MINIO" "$DOMAIN_MINIO_CONSOLE"; do
|
||
[[ -z "$d" ]] && continue
|
||
validate_domain "$d" || fail "Некорректный домен: $d"
|
||
done
|
||
|
||
[[ -n "$DOMAIN_API" && -n "$DOMAIN_FRONTEND" && -n "$DOMAIN_DOCS" ]] \
|
||
|| fail "Домены API, Frontend и Docs обязательны (или используйте --local)"
|
||
|
||
choose_ssl_type_interactive "$(env_get SSL_TYPE none)"
|
||
|
||
case "$SSL_TYPE" in
|
||
none | selfsigned | custom | letsencrypt) ;;
|
||
*) fail "Некорректный SSL_TYPE: $SSL_TYPE (none | selfsigned | custom | letsencrypt)" ;;
|
||
esac
|
||
|
||
if [[ "$SSL_TYPE" == "custom" ]]; then
|
||
prompt_custom_cert_settings
|
||
fi
|
||
|
||
if [[ "$SSL_TYPE" == "letsencrypt" ]]; then
|
||
if [[ "$INSTALL_INTRANET" -eq 1 && "$NON_INTERACTIVE" -ne 1 ]]; then
|
||
warn "Let's Encrypt требует доступ из интернета. Для чистой локальной сети выберите HTTP или selfsigned."
|
||
fi
|
||
prompt CERTBOT_EMAIL "Email для Let's Encrypt" "${default_email:-admin@${DOMAIN_FRONTEND}}"
|
||
[[ -n "$CERTBOT_EMAIL" ]] || fail "Email обязателен для Let's Encrypt"
|
||
else
|
||
CERTBOT_EMAIL=""
|
||
fi
|
||
|
||
derive_public_urls "$SSL_TYPE"
|
||
}
|
||
|
||
write_env_secrets_if_missing() {
|
||
[[ -f .env && "$FORCE_ENV" != "1" ]] && return
|
||
|
||
local pg_pass rmq_pass minio_pass jwt_access jwt_refresh data_key
|
||
pg_pass="$(rand_secret 32)"
|
||
rmq_pass="$(rand_secret 32)"
|
||
minio_pass="$(rand_secret 24)"
|
||
jwt_access="$(rand_secret 64)"
|
||
jwt_refresh="$(rand_secret 64)"
|
||
data_key="$(rand_secret 48)"
|
||
|
||
if [[ ! -f .env ]]; then
|
||
cp .env.example .env 2>/dev/null || touch .env
|
||
fi
|
||
|
||
load_infra_secrets_into_env
|
||
|
||
if docker_volume_exists postgres_data; then
|
||
if [[ -n "$(env_get POSTGRES_PASSWORD "")" ]]; then
|
||
warn "Volume PostgreSQL уже существует — POSTGRES_PASSWORD в .env не меняем"
|
||
else
|
||
fail "Volume PostgreSQL существует, но POSTGRES_PASSWORD не найден. Восстановите .env или выполните: ./install.sh --reset-db"
|
||
fi
|
||
pg_pass="$(env_get POSTGRES_PASSWORD "$pg_pass")"
|
||
fi
|
||
|
||
if docker_volume_exists rabbitmq_data; then
|
||
warn "Volume RabbitMQ уже существует — RABBITMQ_DEFAULT_PASS не меняем"
|
||
rmq_pass="$(env_get RABBITMQ_DEFAULT_PASS "$rmq_pass")"
|
||
fi
|
||
|
||
if docker_volume_exists minio_data; then
|
||
warn "Volume MinIO уже существует — MINIO_ROOT_PASSWORD не меняем"
|
||
minio_pass="$(env_get MINIO_ROOT_PASSWORD "$minio_pass")"
|
||
fi
|
||
|
||
local need_secrets=0
|
||
if [[ "$FORCE_ENV" == "1" ]]; then
|
||
need_secrets=1
|
||
elif [[ "$(env_get POSTGRES_PASSWORD change-me-postgres)" == "change-me-postgres" ]]; then
|
||
need_secrets=1
|
||
elif [[ "$(env_get JWT_ACCESS_SECRET change-me-access-secret)" == "change-me-access-secret" ]]; then
|
||
need_secrets=1
|
||
fi
|
||
|
||
if [[ "$need_secrets" -eq 1 ]]; then
|
||
env_set POSTGRES_PASSWORD "$pg_pass"
|
||
env_set RABBITMQ_DEFAULT_PASS "$rmq_pass"
|
||
env_set MINIO_ROOT_PASSWORD "$minio_pass"
|
||
env_set JWT_ACCESS_SECRET "$jwt_access"
|
||
env_set JWT_REFRESH_SECRET "$jwt_refresh"
|
||
env_set DATA_ENCRYPTION_KEY "$data_key"
|
||
save_infra_secrets
|
||
ok "Секреты сгенерированы / синхронизированы"
|
||
fi
|
||
}
|
||
|
||
compose_project_name() {
|
||
load_env
|
||
echo "${COMPOSE_PROJECT_NAME:-lendry-id}"
|
||
}
|
||
|
||
docker_volume_name() {
|
||
echo "$(compose_project_name)_$1"
|
||
}
|
||
|
||
docker_volume_exists() {
|
||
docker volume inspect "$(docker_volume_name "$1")" >/dev/null 2>&1
|
||
}
|
||
|
||
save_infra_secrets() {
|
||
load_env
|
||
cat >"$INFRA_SECRETS_FILE" <<EOF
|
||
POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-}
|
||
RABBITMQ_DEFAULT_PASS=${RABBITMQ_DEFAULT_PASS:-}
|
||
MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-}
|
||
EOF
|
||
chmod 600 "$INFRA_SECRETS_FILE" 2>/dev/null || true
|
||
}
|
||
|
||
load_infra_secrets_into_env() {
|
||
[[ -f "$INFRA_SECRETS_FILE" ]] || return 0
|
||
# shellcheck disable=SC1091
|
||
set -a && source "$INFRA_SECRETS_FILE" && set +a
|
||
[[ -n "${POSTGRES_PASSWORD:-}" ]] && env_set POSTGRES_PASSWORD "$POSTGRES_PASSWORD"
|
||
[[ -n "${RABBITMQ_DEFAULT_PASS:-}" ]] && env_set RABBITMQ_DEFAULT_PASS "$RABBITMQ_DEFAULT_PASS"
|
||
[[ -n "${MINIO_ROOT_PASSWORD:-}" ]] && env_set MINIO_ROOT_PASSWORD "$MINIO_ROOT_PASSWORD"
|
||
}
|
||
|
||
verify_postgres_auth() {
|
||
load_env
|
||
load_infra_secrets_into_env
|
||
[[ -f .env ]] || return 0
|
||
docker_cmd compose ps postgres 2>/dev/null | grep -q postgres || return 0
|
||
|
||
local user pass db
|
||
user="$(env_get POSTGRES_USER lendry)"
|
||
pass="$(env_get POSTGRES_PASSWORD "")"
|
||
db="$(env_get POSTGRES_DB lendry_id)"
|
||
[[ -n "$pass" ]] || return 0
|
||
|
||
if docker_cmd compose exec -T -e PGPASSWORD="$pass" postgres \
|
||
psql -U "$user" -d "$db" -c 'SELECT 1' >/dev/null 2>&1; then
|
||
ok "PostgreSQL: пароль из .env подходит"
|
||
save_infra_secrets
|
||
return 0
|
||
fi
|
||
|
||
echo ""
|
||
fail "PostgreSQL: пароль в .env не совпадает с данными в volume (ошибка P1000).
|
||
|
||
Это происходит после FORCE_ENV=1 или смены POSTGRES_PASSWORD при существующей БД.
|
||
|
||
Решения:
|
||
1) Сбросить БД (удалит всех пользователей и данные):
|
||
./install.sh --reset-db --yes && ./install.sh --restart
|
||
|
||
2) Вернуть старый POSTGRES_PASSWORD в .env (если сохранили)
|
||
|
||
3) Если есть файл ${INFRA_SECRETS_FILE} — скопируйте пароль оттуда в .env"
|
||
}
|
||
|
||
action_reset_db() {
|
||
load_env
|
||
local vol
|
||
vol="$(docker_volume_name postgres_data)"
|
||
|
||
if [[ "$NON_INTERACTIVE" -ne 1 ]]; then
|
||
echo ""
|
||
warn "Будет удалён volume ${vol} — все пользователи, сессии и настройки в PostgreSQL пропадут."
|
||
read -r -p "Продолжить? [y/N]: " confirm
|
||
[[ "$confirm" =~ ^[Yy]$ ]] || fail "Отменено"
|
||
fi
|
||
|
||
log "Остановка сервисов..."
|
||
docker_cmd compose --env-file .env --profile proxy down 2>/dev/null || docker_cmd compose --env-file .env down 2>/dev/null || true
|
||
|
||
log "Удаление ${vol}..."
|
||
if docker volume inspect "$vol" >/dev/null 2>&1; then
|
||
docker volume rm "$vol" || fail "Не удалось удалить ${vol}"
|
||
else
|
||
warn "Volume ${vol} не найден — возможно, уже удалён"
|
||
fi
|
||
|
||
ok "PostgreSQL volume удалён. PostgreSQL создастся заново с паролем из .env."
|
||
save_infra_secrets
|
||
}
|
||
|
||
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 <<EOF
|
||
nginx:
|
||
ports:
|
||
- "${http_port}:80"
|
||
- "${https_port}:443"
|
||
EOF
|
||
)"
|
||
fi
|
||
|
||
ldap_fragment="$(build_ldap_compose_fragment)"
|
||
[[ -n "$ldap_fragment" ]] && log "Настройка docker-compose override (LDAP DNS / host network)..."
|
||
|
||
body="${nginx_fragment}${ldap_fragment}"
|
||
if [[ -z "$body" ]]; then
|
||
[[ -f "$COMPOSE_OVERRIDE" ]] && rm -f "$COMPOSE_OVERRIDE"
|
||
return
|
||
fi
|
||
|
||
cat >"$COMPOSE_OVERRIDE" <<EOF
|
||
services:
|
||
${body}
|
||
EOF
|
||
}
|
||
|
||
build_ldap_compose_fragment() {
|
||
load_env
|
||
local use_host dns_servers dns_search extra_hosts
|
||
use_host="$(env_get LDAP_USE_HOST_NETWORK false)"
|
||
dns_servers="$(env_get LDAP_DNS_SERVERS "")"
|
||
dns_search="$(env_get LDAP_DNS_SEARCH "")"
|
||
extra_hosts="$(env_get LDAP_EXTRA_HOSTS "")"
|
||
|
||
[[ "$use_host" == "true" || -n "$dns_servers" || -n "$extra_hosts" ]] || return 0
|
||
|
||
if [[ "$use_host" == "true" ]]; then
|
||
cat <<'EOF'
|
||
|
||
ldap-auth:
|
||
network_mode: host
|
||
sso-core:
|
||
environment:
|
||
LDAP_AUTH_URL: http://host.docker.internal:8086
|
||
extra_hosts:
|
||
- "host.docker.internal:host-gateway"
|
||
EOF
|
||
return 0
|
||
fi
|
||
|
||
local fragment=$'\n ldap-auth:'
|
||
if [[ -n "$dns_servers" ]]; then
|
||
fragment+=$'\n dns:'
|
||
local server
|
||
IFS=',' read -ra _dns <<< "$dns_servers"
|
||
for server in "${_dns[@]}"; do
|
||
server="$(echo "$server" | xargs)"
|
||
[[ -n "$server" ]] && fragment+=$'\n - "'"${server}"'"'
|
||
done
|
||
fi
|
||
if [[ -n "$dns_search" ]]; then
|
||
fragment+=$'\n dns_search:'
|
||
local zone
|
||
IFS=',' read -ra _zones <<< "$dns_search"
|
||
for zone in "${_zones[@]}"; do
|
||
zone="$(echo "$zone" | xargs)"
|
||
[[ -n "$zone" ]] && fragment+=$'\n - "'"${zone}"'"'
|
||
done
|
||
fi
|
||
if [[ -n "$extra_hosts" ]]; then
|
||
fragment+=$'\n extra_hosts:'
|
||
local host_entry
|
||
IFS=',' read -ra _hosts <<< "$extra_hosts"
|
||
for host_entry in "${_hosts[@]}"; do
|
||
host_entry="$(echo "$host_entry" | xargs)"
|
||
[[ -n "$host_entry" ]] && fragment+=$'\n - "'"${host_entry}"'"'
|
||
done
|
||
fi
|
||
printf '%s' "$fragment"
|
||
}
|
||
|
||
HOST_EDGE_SERVICES=(api-gateway frontend docs media-ws)
|
||
|
||
recreate_host_port_services() {
|
||
load_env
|
||
[[ "$(env_get NGINX_MODE docker)" == "host" ]] || return 0
|
||
[[ -n "$(env_get DOMAIN_API "")" ]] || return 0
|
||
[[ "$(env_get INSTALL_MODE local)" == "local" ]] && return 0
|
||
|
||
log "Пересоздание сервисов с пробросом портов на 127.0.0.1..."
|
||
build_compose_stack_cmd
|
||
docker_cmd "${COMPOSE_STACK_CMD[@]}" up -d --force-recreate "${HOST_EDGE_SERVICES[@]}"
|
||
}
|
||
|
||
docker_publishes_port() {
|
||
local container="$1"
|
||
local host_port="$2"
|
||
docker_cmd port "$container" 2>/dev/null | grep -q "127.0.0.1:${host_port}" || \
|
||
docker_cmd port "$container" 2>/dev/null | grep -q ":${host_port}->"
|
||
}
|
||
|
||
ensure_host_upstreams() {
|
||
load_env
|
||
[[ "$(env_get NGINX_MODE docker)" == "host" ]] || return 0
|
||
[[ -n "$(env_get DOMAIN_API "")" ]] || return 0
|
||
[[ "$(env_get INSTALL_MODE local)" == "local" ]] && return 0
|
||
|
||
local missing=0
|
||
for spec in "3000:API" "3002:Frontend" "3003:Docs"; do
|
||
local port="${spec%%:*}"
|
||
if ! port_in_use "$port"; then
|
||
warn "Порт ${port} (${spec#*:}) не слушается на хосте"
|
||
missing=1
|
||
fi
|
||
done
|
||
|
||
if [[ "$missing" -eq 1 ]]; then
|
||
warn "Проброс портов не применён — пересоздаём edge-сервисы (NGINX_MODE=host)"
|
||
recreate_host_port_services
|
||
local attempt=0
|
||
while [[ $attempt -lt 12 ]]; do
|
||
missing=0
|
||
for spec in "3000:API" "3002:Frontend" "3003:Docs"; do
|
||
local port="${spec%%:*}"
|
||
if ! port_in_use "$port"; then
|
||
missing=1
|
||
fi
|
||
done
|
||
[[ "$missing" -eq 0 ]] && break
|
||
attempt=$((attempt + 1))
|
||
sleep 5
|
||
done
|
||
for spec in "3000:API" "3002:Frontend" "3003:Docs"; do
|
||
local port="${spec%%:*}"
|
||
if ! port_in_use "$port"; then
|
||
echo ""
|
||
warn "Диагностика:"
|
||
docker_cmd ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' 2>/dev/null | sed 's/^/ /' || true
|
||
docker_cmd port lendry-id-frontend 2>/dev/null | sed 's/^/ frontend: /' || echo " frontend: порты не опубликованы"
|
||
echo ""
|
||
fail "Порт ${port} недоступен. Проверьте: grep NGINX_MODE .env && cat docker-compose.override.yml && docker compose logs frontend"
|
||
fi
|
||
done
|
||
ok "Порты 3000/3002/3003 доступны на localhost"
|
||
fi
|
||
}
|
||
|
||
nginx_upstream() {
|
||
case "$1" in
|
||
api)
|
||
if [[ "$NGINX_MODE" == "docker" ]]; then
|
||
echo "http://api-gateway:3000"
|
||
else
|
||
echo "http://127.0.0.1:3000"
|
||
fi
|
||
;;
|
||
frontend)
|
||
[[ "$NGINX_MODE" == "docker" ]] && echo "http://frontend:3000" || echo "http://127.0.0.1:3002"
|
||
;;
|
||
docs)
|
||
[[ "$NGINX_MODE" == "docker" ]] && echo "http://docs:3000" || echo "http://127.0.0.1:3003"
|
||
;;
|
||
ws)
|
||
[[ "$NGINX_MODE" == "docker" ]] && echo "http://media-ws:8085" || echo "http://127.0.0.1:8085"
|
||
;;
|
||
minio)
|
||
[[ "$NGINX_MODE" == "docker" ]] && echo "http://minio:9000" || echo "http://127.0.0.1:9000"
|
||
;;
|
||
minio-console)
|
||
[[ "$NGINX_MODE" == "docker" ]] && echo "http://minio:9001" || echo "http://127.0.0.1:9001"
|
||
;;
|
||
esac
|
||
}
|
||
|
||
nginx_cert_base_path() {
|
||
local domain="$1"
|
||
local ssl_type="$2"
|
||
if [[ "$ssl_type" == "letsencrypt" ]]; then
|
||
echo "/etc/letsencrypt/live/${domain}"
|
||
elif [[ "$ssl_type" == "selfsigned" ]]; then
|
||
if [[ "$NGINX_MODE" == "docker" ]]; then
|
||
echo "/etc/nginx/certs/shared-intranet"
|
||
else
|
||
echo "${LOCAL_CERTS_DIR}/shared-intranet"
|
||
fi
|
||
elif [[ "$ssl_type" == "custom" ]]; then
|
||
if [[ "$NGINX_MODE" == "docker" ]]; then
|
||
echo "/etc/nginx/certs/custom"
|
||
else
|
||
echo "${LOCAL_CERTS_DIR}/custom"
|
||
fi
|
||
elif [[ "$NGINX_MODE" == "docker" ]]; then
|
||
echo "/etc/nginx/certs/${domain}"
|
||
else
|
||
echo "${LOCAL_CERTS_DIR}/${domain}"
|
||
fi
|
||
}
|
||
|
||
intranet_wildcard_base_domain() {
|
||
local domain="${1:-}"
|
||
[[ -z "$domain" ]] && return 0
|
||
if [[ "$domain" == *.*.* ]]; then
|
||
echo "${domain#*.}"
|
||
else
|
||
echo "$domain"
|
||
fi
|
||
}
|
||
|
||
# Регистрируемый домен (eTLD+1) — нужен для FedCM well-known.
|
||
# FedCM ВСЕГДА запрашивает /.well-known/web-identity с eTLD+1 домена IdP:
|
||
# sso.idpmvk.lpr -> idpmvk.lpr
|
||
# api.id.example.ru -> example.ru
|
||
# Берём два последних лейбла (корректно для большинства зон и кастомных TLD .lpr).
|
||
# Исключение — многосоставные суффиксы вида .co.uk; для них укажите домен вручную.
|
||
registrable_domain() {
|
||
local domain="${1:-}"
|
||
[[ -z "$domain" ]] && return 0
|
||
local -a labels
|
||
IFS='.' read -ra labels <<< "$domain"
|
||
local n=${#labels[@]}
|
||
if (( n <= 2 )); then
|
||
echo "$domain"
|
||
else
|
||
echo "${labels[n-2]}.${labels[n-1]}"
|
||
fi
|
||
}
|
||
|
||
collect_oauth_redirect_sans_hosts() {
|
||
local f="${LOCAL_CERTS_DIR}/oauth-redirect-sans.txt"
|
||
[[ -f "$f" ]] || return 0
|
||
local host
|
||
while IFS= read -r host || [[ -n "$host" ]]; do
|
||
host="${host#"${host%%[![:space:]]*}"}"
|
||
host="${host%"${host##*[![:space:]]}"}"
|
||
[[ -z "$host" || "$host" == \#* ]] && continue
|
||
echo "$host"
|
||
done < "$f"
|
||
}
|
||
|
||
# Нормализованный список SAN для сравнения сертификата с желаемым набором.
|
||
self_signed_sans_fingerprint() {
|
||
collect_self_signed_sans | tr ',' '\n' | sed 's/^DNS://' | sort -u | paste -sd, -
|
||
}
|
||
|
||
current_self_signed_sans_fingerprint() {
|
||
local cert="${LOCAL_CERTS_DIR}/shared-intranet/fullchain.pem"
|
||
[[ -f "$cert" ]] || return 1
|
||
openssl x509 -in "$cert" -noout -ext subjectAltName 2>/dev/null \
|
||
| sed 's/ *subjectAltName=//;s/DNS://g' \
|
||
| tr ',' '\n' | sed 's/^ *//' | sort -u | paste -sd, -
|
||
}
|
||
|
||
renew_self_signed_cert_if_sans_changed() {
|
||
[[ "$(env_get SSL_TYPE none)" != "selfsigned" ]] && return 0
|
||
local desired current
|
||
desired="$(self_signed_sans_fingerprint)"
|
||
current="$(current_self_signed_sans_fingerprint 2>/dev/null || true)"
|
||
if [[ -z "$current" || "$desired" != "$current" ]]; then
|
||
if [[ -n "$current" ]]; then
|
||
log "SAN сертификата изменились (OAuth redirect URI) — пересоздаём self-signed"
|
||
fi
|
||
generate_all_self_signed_certs --force
|
||
return 0
|
||
fi
|
||
}
|
||
|
||
collect_self_signed_sans() {
|
||
local -a sans=()
|
||
local base domain
|
||
|
||
load_env
|
||
for domain in "$DOMAIN_API" "$DOMAIN_FRONTEND" "$DOMAIN_DOCS" "${DOMAIN_WS:-}" "${DOMAIN_MINIO:-}" "${DOMAIN_MINIO_CONSOLE:-}"; do
|
||
[[ -z "$domain" ]] && continue
|
||
sans+=("DNS:${domain}")
|
||
done
|
||
|
||
base="$(intranet_wildcard_base_domain "${DOMAIN_FRONTEND:-$DOMAIN_API}")"
|
||
if [[ -n "$base" ]]; then
|
||
sans+=("DNS:*.${base}")
|
||
sans+=("DNS:${base}")
|
||
fi
|
||
|
||
# eTLD+1 для FedCM well-known (например idpmvk.lpr) — гарантированно в сертификате.
|
||
local apex
|
||
apex="$(registrable_domain "${DOMAIN_FRONTEND:-$DOMAIN_API}")"
|
||
if [[ -n "$apex" && "$apex" != "$base" ]]; then
|
||
sans+=("DNS:${apex}")
|
||
fi
|
||
|
||
# Авто: домены из redirect_uri OAuth-приложений (nginx/certs/oauth-redirect-sans.txt).
|
||
local oauth_host
|
||
while IFS= read -r oauth_host; do
|
||
[[ -z "$oauth_host" ]] && continue
|
||
sans+=("DNS:${oauth_host}")
|
||
done < <(collect_oauth_redirect_sans_hosts)
|
||
|
||
# Ручной override (дополнительно к OAuth), через запятую в .env.
|
||
local extra_sans="${EXTRA_SSL_SANS:-}"
|
||
if [[ -n "$extra_sans" ]]; then
|
||
local entry
|
||
IFS=',' read -ra _extra_sans <<< "$extra_sans"
|
||
for entry in "${_extra_sans[@]}"; do
|
||
entry="${entry#"${entry%%[![:space:]]*}"}"
|
||
entry="${entry%"${entry##*[![:space:]]}"}"
|
||
[[ -z "$entry" ]] && continue
|
||
sans+=("DNS:${entry}")
|
||
done
|
||
fi
|
||
|
||
# Уникальные SAN
|
||
local -A _seen=()
|
||
local -a unique=()
|
||
local item key
|
||
for item in "${sans[@]}"; do
|
||
key="${item#DNS:}"
|
||
[[ -n "${_seen[$key]:-}" ]] && continue
|
||
_seen[$key]=1
|
||
unique+=("$item")
|
||
done
|
||
(IFS=,; echo "${unique[*]}")
|
||
}
|
||
|
||
find_custom_cert_file() {
|
||
local dir="$1"
|
||
local preferred="${2:-}"
|
||
shift 2 || true
|
||
local -a fallbacks=("$@")
|
||
local name
|
||
|
||
if [[ -n "$preferred" ]]; then
|
||
[[ -f "${dir}/${preferred}" ]] || fail "Файл не найден: ${dir}/${preferred}"
|
||
echo "${dir}/${preferred}"
|
||
return 0
|
||
fi
|
||
|
||
for name in "${fallbacks[@]}"; do
|
||
if [[ -f "${dir}/${name}" ]]; then
|
||
echo "${dir}/${name}"
|
||
return 0
|
||
fi
|
||
done
|
||
|
||
return 1
|
||
}
|
||
|
||
find_custom_cert_file_auto() {
|
||
local dir="$1"
|
||
local kind="$2"
|
||
local f base
|
||
|
||
shopt -s nullglob
|
||
if [[ "$kind" == "key" ]]; then
|
||
for f in "$dir"/*-key.pem "$dir"/*_key.pem "$dir"/*.key; do
|
||
if [[ -f "$f" ]]; then
|
||
shopt -u nullglob
|
||
echo "$f"
|
||
return 0
|
||
fi
|
||
done
|
||
shopt -u nullglob
|
||
return 1
|
||
fi
|
||
|
||
for f in "$dir"/_wildcard*.pem "$dir"/*wildcard*.pem "$dir"/*.pem "$dir"/*.crt; do
|
||
[[ -f "$f" ]] || continue
|
||
base="$(basename "$f")"
|
||
[[ "$base" == *-key.pem || "$base" == *_key.pem ]] && continue
|
||
shopt -u nullglob
|
||
echo "$f"
|
||
return 0
|
||
done
|
||
shopt -u nullglob
|
||
return 1
|
||
}
|
||
|
||
resolve_custom_cert_source_dir() {
|
||
if [[ -n "$CLI_CERT_DIR" ]]; then
|
||
SSL_CERT_DIR="$CLI_CERT_DIR"
|
||
elif [[ -n "${SSL_CERT_DIR:-}" ]]; then
|
||
:
|
||
elif [[ -n "$(env_get SSL_CERT_DIR "")" ]]; then
|
||
SSL_CERT_DIR="$(env_get SSL_CERT_DIR "")"
|
||
elif [[ -d "$DEFAULT_CERT_UPLOAD_DIR" ]]; then
|
||
SSL_CERT_DIR="$DEFAULT_CERT_UPLOAD_DIR"
|
||
fi
|
||
}
|
||
|
||
prompt_custom_cert_settings() {
|
||
resolve_custom_cert_source_dir
|
||
local default_dir="${SSL_CERT_DIR:-$DEFAULT_CERT_UPLOAD_DIR}"
|
||
local default_fullchain="${CLI_CERT_FULLCHAIN:-$(env_get SSL_CERT_FULLCHAIN "")}"
|
||
local default_key="${CLI_CERT_KEY:-$(env_get SSL_CERT_KEY "")}"
|
||
|
||
if [[ "$NON_INTERACTIVE" -eq 1 ]]; then
|
||
SSL_CERT_DIR="${SSL_CERT_DIR:-$default_dir}"
|
||
SSL_CERT_FULLCHAIN="${SSL_CERT_FULLCHAIN:-$default_fullchain}"
|
||
SSL_CERT_KEY="${SSL_CERT_KEY:-$default_key}"
|
||
[[ -d "$SSL_CERT_DIR" ]] || fail "Папка с сертификатами не найдена: $SSL_CERT_DIR (укажите --cert-dir PATH)"
|
||
return 0
|
||
fi
|
||
|
||
echo ""
|
||
echo -e "${BOLD}Свои SSL-сертификаты:${NC}"
|
||
echo " Положите на сервер файлы сертификата и ключа, затем укажите папку."
|
||
echo " Поддерживаются имена: fullchain.pem, cert.pem, _wildcard.domain.pem"
|
||
echo " и ключи: privkey.pem, key.pem, _wildcard.domain-key.pem"
|
||
echo " Рекомендуемая папка: ${DEFAULT_CERT_UPLOAD_DIR}/"
|
||
echo ""
|
||
|
||
prompt SSL_CERT_DIR "Папка с сертификатами на сервере" "$default_dir"
|
||
[[ -d "$SSL_CERT_DIR" ]] || fail "Папка не найдена: $SSL_CERT_DIR"
|
||
|
||
prompt_optional SSL_CERT_FULLCHAIN "Имя файла сертификата (полная цепочка, пусто = авто)" "$default_fullchain"
|
||
prompt_optional SSL_CERT_KEY "Имя файла приватного ключа (пусто = авто)" "$default_key"
|
||
}
|
||
|
||
validate_custom_certificate_pair() {
|
||
local fullchain="$1"
|
||
local key="$2"
|
||
|
||
[[ -f "$fullchain" ]] || fail "Файл сертификата не найден: $fullchain"
|
||
[[ -f "$key" ]] || fail "Файл ключа не найден: $key"
|
||
[[ -r "$fullchain" ]] || fail "Нет прав на чтение сертификата: $fullchain"
|
||
[[ -r "$key" ]] || fail "Нет прав на чтение ключа: $key"
|
||
|
||
has_cmd openssl || return 0
|
||
|
||
openssl x509 -in "$fullchain" -noout >/dev/null 2>&1 \
|
||
|| fail "Некорректный файл сертификата: $fullchain"
|
||
|
||
if openssl rsa -in "$key" -check -noout >/dev/null 2>&1 \
|
||
|| openssl ec -in "$key" -check -noout >/dev/null 2>&1 \
|
||
|| openssl pkey -in "$key" -noout >/dev/null 2>&1; then
|
||
:
|
||
else
|
||
fail "Некорректный файл приватного ключа: $key"
|
||
fi
|
||
|
||
if ! openssl x509 -noout -modulus -in "$fullchain" 2>/dev/null \
|
||
| cmp -s <(openssl rsa -noout -modulus -in "$key" 2>/dev/null) - 2>/dev/null \
|
||
&& ! openssl x509 -noout -modulus -in "$fullchain" 2>/dev/null \
|
||
| cmp -s <(openssl ec -noout -modulus -in "$key" 2>/dev/null) - 2>/dev/null \
|
||
&& ! openssl x509 -noout -pubkey -in "$fullchain" 2>/dev/null \
|
||
| cmp -s <(openssl pkey -pubout -in "$key" 2>/dev/null) - 2>/dev/null; then
|
||
warn "Не удалось проверить соответствие сертификата и ключа — продолжаем установку"
|
||
fi
|
||
}
|
||
|
||
install_custom_certificates() {
|
||
local force="${1:-}"
|
||
load_env
|
||
|
||
resolve_custom_cert_source_dir
|
||
SSL_CERT_DIR="${SSL_CERT_DIR:-$(env_get SSL_CERT_DIR "")}"
|
||
SSL_CERT_FULLCHAIN="${SSL_CERT_FULLCHAIN:-$(env_get SSL_CERT_FULLCHAIN "")}"
|
||
SSL_CERT_KEY="${SSL_CERT_KEY:-$(env_get SSL_CERT_KEY "")}"
|
||
|
||
[[ -n "$SSL_CERT_DIR" ]] || fail "Не задана папка с сертификатами (SSL_CERT_DIR или --cert-dir)"
|
||
[[ -d "$SSL_CERT_DIR" ]] || fail "Папка с сертификатами не найдена: $SSL_CERT_DIR"
|
||
|
||
local src_fullchain src_key
|
||
src_fullchain="$(find_custom_cert_file "$SSL_CERT_DIR" "$SSL_CERT_FULLCHAIN" \
|
||
fullchain.pem cert.pem certificate.pem server.crt)" \
|
||
|| src_fullchain="$(find_custom_cert_file_auto "$SSL_CERT_DIR" cert)" \
|
||
|| fail "Не найден файл сертификата в ${SSL_CERT_DIR}. Положите fullchain.pem, _wildcard.domain.pem и т.п., либо укажите --cert-fullchain"
|
||
src_key="$(find_custom_cert_file "$SSL_CERT_DIR" "$SSL_CERT_KEY" \
|
||
privkey.pem key.pem server.key)" \
|
||
|| src_key="$(find_custom_cert_file_auto "$SSL_CERT_DIR" key)" \
|
||
|| fail "Не найден файл ключа в ${SSL_CERT_DIR}. Положите privkey.pem, domain-key.pem и т.п., либо укажите --cert-key"
|
||
|
||
validate_custom_certificate_pair "$src_fullchain" "$src_key"
|
||
|
||
if [[ "$force" != "--force" \
|
||
&& -f "${CUSTOM_CERT_TARGET_DIR}/fullchain.pem" \
|
||
&& -f "${CUSTOM_CERT_TARGET_DIR}/privkey.pem" ]] \
|
||
&& cmp -s "$src_fullchain" "${CUSTOM_CERT_TARGET_DIR}/fullchain.pem" \
|
||
&& cmp -s "$src_key" "${CUSTOM_CERT_TARGET_DIR}/privkey.pem"; then
|
||
log "Сертификаты уже установлены: ${CUSTOM_CERT_TARGET_DIR}/"
|
||
return 0
|
||
fi
|
||
|
||
log "Установка своих сертификатов из ${SSL_CERT_DIR}..."
|
||
mkdir -p "$CUSTOM_CERT_TARGET_DIR"
|
||
cp "$src_fullchain" "${CUSTOM_CERT_TARGET_DIR}/fullchain.pem"
|
||
cp "$src_key" "${CUSTOM_CERT_TARGET_DIR}/privkey.pem"
|
||
chmod 644 "${CUSTOM_CERT_TARGET_DIR}/fullchain.pem" 2>/dev/null || true
|
||
chmod 600 "${CUSTOM_CERT_TARGET_DIR}/privkey.pem" 2>/dev/null || true
|
||
|
||
env_set SSL_CERT_DIR "$SSL_CERT_DIR"
|
||
env_set SSL_CERT_FULLCHAIN "$SSL_CERT_FULLCHAIN"
|
||
env_set SSL_CERT_KEY "$SSL_CERT_KEY"
|
||
|
||
ok "Сертификаты установлены: ${CUSTOM_CERT_TARGET_DIR}/"
|
||
if has_cmd openssl; then
|
||
log "Срок действия: $(openssl x509 -in "${CUSTOM_CERT_TARGET_DIR}/fullchain.pem" -noout -enddate 2>/dev/null | cut -d= -f2-)"
|
||
log "Домены (SAN): $(openssl x509 -in "${CUSTOM_CERT_TARGET_DIR}/fullchain.pem" -noout -ext subjectAltName 2>/dev/null | sed 's/ *//g' || echo "не указаны")"
|
||
fi
|
||
}
|
||
|
||
generate_shared_self_signed_cert() {
|
||
local force="${1:-}"
|
||
local cert_dir="${LOCAL_CERTS_DIR}/shared-intranet"
|
||
local base sans cn
|
||
|
||
mkdir -p "$cert_dir"
|
||
base="$(intranet_wildcard_base_domain "${DOMAIN_FRONTEND:-$DOMAIN_API}")"
|
||
[[ -z "$base" ]] && base="${DOMAIN_FRONTEND:-$DOMAIN_API}"
|
||
cn="*.${base}"
|
||
sans="$(collect_self_signed_sans)"
|
||
|
||
if [[ -f "${cert_dir}/fullchain.pem" && -f "${cert_dir}/privkey.pem" && "$force" != "--force" ]]; then
|
||
return 0
|
||
fi
|
||
|
||
log "Общий самоподписанный сертификат: ${cn} (${sans})"
|
||
if has_cmd openssl; then
|
||
openssl req -x509 -nodes -days 825 -newkey rsa:2048 \
|
||
-keyout "${cert_dir}/privkey.pem" \
|
||
-out "${cert_dir}/fullchain.pem" \
|
||
-subj "/CN=${cn}/O=Lendry ID Intranet/C=RU" \
|
||
-addext "subjectAltName=${sans}" 2>/dev/null || \
|
||
openssl req -x509 -nodes -days 825 -newkey rsa:2048 \
|
||
-keyout "${cert_dir}/privkey.pem" \
|
||
-out "${cert_dir}/fullchain.pem" \
|
||
-subj "/CN=${cn}/O=Lendry ID Intranet/C=RU"
|
||
chmod 600 "${cert_dir}/privkey.pem" 2>/dev/null || true
|
||
else
|
||
fail "openssl не найден — нужен для самоподписанных сертификатов"
|
||
fi
|
||
}
|
||
|
||
generate_all_self_signed_certs() {
|
||
local force="${1:-}"
|
||
load_env
|
||
if [[ "$force" == "--force" ]]; then
|
||
rm -f "${LOCAL_CERTS_DIR}/shared-intranet/fullchain.pem" "${LOCAL_CERTS_DIR}/shared-intranet/privkey.pem"
|
||
fi
|
||
generate_shared_self_signed_cert "$force"
|
||
ok "Самоподписанный сертификат создан: ${LOCAL_CERTS_DIR}/shared-intranet"
|
||
}
|
||
|
||
nginx_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 75s;"
|
||
|
||
# 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 75s;"
|
||
|
||
# proxy_pass к корню апстрима БЕЗ $request_uri — иначе nginx считает директиву
|
||
# «variable proxy_pass» и требует resolver (502: no resolver defined to resolve lendry-id-frontend).
|
||
# Без URI в proxy_pass полный путь клиента передаётся upstream как есть.
|
||
render_proxy_root() {
|
||
local key="$1" up var
|
||
up="$(nginx_upstream "$key")"
|
||
if [[ "$NGINX_MODE" == "docker" ]]; then
|
||
var="idp_up_${key//-/_}"
|
||
printf ' set $%s %s;\n proxy_pass $%s;' "$var" "$up" "$var"
|
||
return 0
|
||
fi
|
||
printf ' proxy_pass %s;' "$up"
|
||
}
|
||
|
||
# proxy_pass с заменой пути на фиксированный ($2, например /ws или /oauth/).
|
||
render_proxy_rewrite() {
|
||
local key="$1" target="$2" up var
|
||
up="$(nginx_upstream "$key")"
|
||
if [[ "$NGINX_MODE" == "docker" ]]; then
|
||
var="idp_up_${key//-/_}"
|
||
printf ' set $%s %s;\n rewrite ^.*$ %s break;\n proxy_pass $%s;' "$var" "$up" "$target" "$var"
|
||
return 0
|
||
fi
|
||
printf ' rewrite ^.*$ %s break;\n proxy_pass %s;' "$target" "$up"
|
||
}
|
||
|
||
# proxy_pass для /idp-api/ — в Docker через variable proxy_pass, чтобы nginx
|
||
# не держал stale IP api-gateway после recreate и резолвил через 127.0.0.11.
|
||
render_proxy_idp_api() {
|
||
local up
|
||
up="$(nginx_upstream api)"
|
||
if [[ "$NGINX_MODE" == "docker" ]]; then
|
||
printf ' set $idp_up_api %s;\n rewrite ^/idp-api/?(.*)$ /$1 break;\n proxy_pass $idp_up_api;' "$up"
|
||
return 0
|
||
fi
|
||
printf ' proxy_pass %s/;' "$up"
|
||
}
|
||
|
||
# WebSocket /idp-api/ws → media-ws /ws.
|
||
render_proxy_idp_ws() {
|
||
local up
|
||
up="$(nginx_upstream ws)"
|
||
if [[ "$NGINX_MODE" == "docker" ]]; then
|
||
printf ' set $idp_up_ws %s;\n rewrite ^/idp-api/ws$ /ws break;\n rewrite ^/idp-api/ws/(.*)$ /ws/$1 break;\n proxy_pass $idp_up_ws;' "$up"
|
||
return 0
|
||
fi
|
||
printf ' proxy_pass %s/ws;' "$up"
|
||
}
|
||
|
||
# Старые конфиги с $request_uri дают 502 «no resolver defined» на location /.
|
||
patch_legacy_nginx_proxy_pass() {
|
||
[[ "$NGINX_MODE" == "docker" ]] || return 0
|
||
local conf patched=0
|
||
shopt -s nullglob
|
||
for conf in "${LOCAL_CONF_DIR}/${NGINX_PREFIX}-"*.conf; do
|
||
[[ -f "$conf" ]] || continue
|
||
if grep -q 'proxy_pass.*\$request_uri' "$conf" 2>/dev/null; then
|
||
log "Патч legacy proxy_pass \$request_uri → $(basename "$conf")"
|
||
sed -i -E 's|proxy_pass (http://[^;]+)\$request_uri;|proxy_pass \1;|g' "$conf"
|
||
patched=1
|
||
fi
|
||
done
|
||
shopt -u nullglob
|
||
if [[ $patched -eq 1 ]]; then
|
||
ok "Legacy proxy_pass \$request_uri исправлен в nginx/conf.d"
|
||
reload_docker_nginx || true
|
||
fi
|
||
}
|
||
|
||
nginx_write_conf() {
|
||
local conf="$1"
|
||
if [[ "$NGINX_MODE" == "docker" ]]; then
|
||
cat >"$conf"
|
||
else
|
||
run_root tee "$conf" >/dev/null
|
||
fi
|
||
}
|
||
|
||
nginx_ssl_directives() {
|
||
local domain="$1"
|
||
local ssl_type="$2"
|
||
local cert_base
|
||
cert_base="$(nginx_cert_base_path "$domain" "$ssl_type")"
|
||
if [[ "$ssl_type" == "letsencrypt" ]]; then
|
||
cat <<EOF
|
||
ssl_certificate ${cert_base}/fullchain.pem;
|
||
ssl_certificate_key ${cert_base}/privkey.pem;
|
||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||
EOF
|
||
else
|
||
cat <<EOF
|
||
ssl_certificate ${cert_base}/fullchain.pem;
|
||
ssl_certificate_key ${cert_base}/privkey.pem;
|
||
ssl_protocols TLSv1.2 TLSv1.3;
|
||
ssl_prefer_server_ciphers off;
|
||
EOF
|
||
fi
|
||
}
|
||
|
||
nginx_http_server_preamble() {
|
||
local domain="$1"
|
||
local ssl_type="$2"
|
||
if [[ "$ssl_type" == "letsencrypt" ]]; then
|
||
cat <<EOF
|
||
server {
|
||
listen 80;
|
||
listen [::]:80;
|
||
server_name ${domain};
|
||
|
||
location /.well-known/acme-challenge/ {
|
||
root ${CERTBOT_WEBROOT};
|
||
}
|
||
|
||
location / {
|
||
return 301 https://\$host\$request_uri;
|
||
}
|
||
}
|
||
|
||
EOF
|
||
elif [[ "$ssl_type" == "selfsigned" || "$ssl_type" == "custom" ]]; then
|
||
cat <<EOF
|
||
server {
|
||
listen 80;
|
||
listen [::]:80;
|
||
server_name ${domain};
|
||
|
||
location / {
|
||
return 301 https://\$host\$request_uri;
|
||
}
|
||
}
|
||
|
||
EOF
|
||
fi
|
||
}
|
||
|
||
build_ws_block() {
|
||
local ws_on_api="$1"
|
||
[[ "$ws_on_api" != "true" ]] && return 0
|
||
local proxy_ws
|
||
proxy_ws="$(render_proxy_rewrite ws /ws)"
|
||
cat <<EOF
|
||
location /ws {
|
||
${proxy_ws}
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Upgrade \$http_upgrade;
|
||
proxy_set_header Connection "upgrade";
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_read_timeout 86400s;
|
||
}
|
||
|
||
EOF
|
||
}
|
||
|
||
# Блок location / для отдельного домена API (api.example.com).
|
||
# CORS полностью на api-gateway (enableCors) — nginx только проксирует,
|
||
# иначе add_header/if в nginx ломают ответы на авторизованные cross-origin запросы (502).
|
||
# docker: НЕ используем upstream{} — nginx резолвит api-gateway один раз при старте
|
||
# и после пересборки контейнера (новый IP) все запросы дают 502 Connection refused.
|
||
build_nginx_api_upstream_block() {
|
||
if [[ "$NGINX_MODE" == "docker" ]]; then
|
||
return 0
|
||
fi
|
||
cat <<EOF
|
||
upstream ${NGINX_PREFIX}_api {
|
||
server 127.0.0.1:3000;
|
||
keepalive 64;
|
||
}
|
||
EOF
|
||
}
|
||
|
||
build_nginx_api_location_block() {
|
||
if [[ "$NGINX_MODE" == "docker" ]]; then
|
||
local proxy
|
||
proxy="$(render_proxy_root api)"
|
||
cat <<EOF
|
||
location / {
|
||
${proxy}
|
||
${nginx_proxy_headers_api}
|
||
}
|
||
EOF
|
||
return 0
|
||
fi
|
||
cat <<EOF
|
||
location / {
|
||
proxy_pass http://${NGINX_PREFIX}_api;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Connection "";
|
||
${nginx_proxy_headers}
|
||
}
|
||
EOF
|
||
}
|
||
|
||
write_nginx_site_api() {
|
||
local domain="$1"
|
||
local ssl_type="${2:-none}"
|
||
local ws_on_api="${3:-true}"
|
||
local conf ws_block http_preamble ssl_block api_upstream api_location
|
||
conf="$(nginx_conf_target api)"
|
||
ws_block="$(build_ws_block "$ws_on_api")"
|
||
http_preamble="$(nginx_http_server_preamble "$domain" "$ssl_type")"
|
||
api_upstream="$(build_nginx_api_upstream_block)"
|
||
api_location="$(build_nginx_api_location_block)"
|
||
|
||
if [[ "$ssl_type" == "none" ]]; then
|
||
nginx_write_conf "$conf" <<EOF
|
||
${api_upstream}
|
||
server {
|
||
listen 80;
|
||
listen [::]:80;
|
||
server_name ${domain};
|
||
client_max_body_size 100m;
|
||
${ws_block}
|
||
${api_location}
|
||
}
|
||
EOF
|
||
else
|
||
ssl_block="$(nginx_ssl_directives "$domain" "$ssl_type")"
|
||
nginx_write_conf "$conf" <<EOF
|
||
${api_upstream}
|
||
${http_preamble}server {
|
||
listen 443 ssl http2;
|
||
listen [::]:443 ssl http2;
|
||
server_name ${domain};
|
||
${ssl_block}
|
||
client_max_body_size 100m;
|
||
${ws_block}
|
||
${api_location}
|
||
}
|
||
EOF
|
||
fi
|
||
nginx_publish_config "api"
|
||
}
|
||
|
||
build_nginx_idp_api_proxy_block() {
|
||
local proxy_ws proxy_idp proxy_oauth proxy_wk proxy_fedcm
|
||
proxy_ws="$(render_proxy_idp_ws)"
|
||
proxy_idp="$(render_proxy_idp_api)"
|
||
proxy_oauth="$(render_proxy_root api)"
|
||
proxy_wk="$(render_proxy_root api)"
|
||
proxy_fedcm="$(render_proxy_root api)"
|
||
cat <<EOF
|
||
# ^~ обязателен: иначе regex ниже перехватывает /idp-api/ws и шлёт WS на api-gateway вместо media-ws
|
||
location ^~ /idp-api/ws {
|
||
${proxy_ws}
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Upgrade \$http_upgrade;
|
||
proxy_set_header Connection "upgrade";
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_read_timeout 86400s;
|
||
proxy_connect_timeout 75s;
|
||
}
|
||
|
||
location ^~ /idp-api/ {
|
||
${proxy_idp}
|
||
${nginx_proxy_headers_api}
|
||
}
|
||
|
||
location /oauth/ {
|
||
${proxy_oauth}
|
||
${nginx_proxy_headers_api}
|
||
}
|
||
|
||
location /.well-known/ {
|
||
${proxy_wk}
|
||
${nginx_proxy_headers_api}
|
||
}
|
||
|
||
location /fedcm/ {
|
||
${proxy_fedcm}
|
||
${nginx_proxy_headers_api}
|
||
}
|
||
|
||
EOF
|
||
}
|
||
|
||
nginx_docker_server_dns_block() {
|
||
if [[ "$NGINX_MODE" != "docker" ]]; then
|
||
return 0
|
||
fi
|
||
cat <<'EOF'
|
||
# Docker embedded DNS — страховка для variable proxy_pass и после recreate контейнеров.
|
||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||
EOF
|
||
}
|
||
|
||
write_nginx_site_frontend() {
|
||
local domain="$1"
|
||
local ssl_type="${2:-none}"
|
||
local conf http_preamble ssl_block idp_api_block proxy_root server_dns
|
||
conf="$(nginx_conf_target frontend)"
|
||
proxy_root="$(render_proxy_root frontend)"
|
||
http_preamble="$(nginx_http_server_preamble "$domain" "$ssl_type")"
|
||
idp_api_block="$(build_nginx_idp_api_proxy_block)"
|
||
server_dns="$(nginx_docker_server_dns_block)"
|
||
|
||
if [[ "$ssl_type" == "none" ]]; then
|
||
nginx_write_conf "$conf" <<EOF
|
||
server {
|
||
listen 80;
|
||
listen [::]:80;
|
||
server_name ${domain};
|
||
${server_dns}
|
||
${idp_api_block}
|
||
location / {
|
||
${proxy_root}
|
||
proxy_http_version 1.1;
|
||
${nginx_proxy_headers}
|
||
}
|
||
}
|
||
EOF
|
||
else
|
||
ssl_block="$(nginx_ssl_directives "$domain" "$ssl_type")"
|
||
nginx_write_conf "$conf" <<EOF
|
||
${http_preamble}server {
|
||
listen 443 ssl http2;
|
||
listen [::]:443 ssl http2;
|
||
server_name ${domain};
|
||
${ssl_block}
|
||
client_max_body_size 50m;
|
||
${server_dns}
|
||
${idp_api_block}
|
||
location / {
|
||
${proxy_root}
|
||
proxy_http_version 1.1;
|
||
${nginx_proxy_headers}
|
||
}
|
||
}
|
||
EOF
|
||
fi
|
||
nginx_publish_config "frontend"
|
||
}
|
||
|
||
# Сервер для eTLD+1 (например idpmvk.lpr): обслуживает FedCM well-known.
|
||
# Браузер при FedCM-логине из стороннего приложения запрашивает
|
||
# https://<eTLD+1>/.well-known/web-identity. Если этого домена нет в DNS —
|
||
# получаем ERR_NAME_NOT_RESOLVED. Зеркалим idp-api/fedcm/oauth/.well-known на
|
||
# api-gateway и корень на фронтенд, чтобы домен был полноценным алиасом IdP.
|
||
write_nginx_site_fedcm_apex() {
|
||
local domain="$1"
|
||
local ssl_type="${2:-none}"
|
||
local conf http_preamble ssl_block idp_api_block proxy_root server_dns
|
||
conf="$(nginx_conf_target fedcm-apex)"
|
||
proxy_root="$(render_proxy_root frontend)"
|
||
http_preamble="$(nginx_http_server_preamble "$domain" "$ssl_type")"
|
||
idp_api_block="$(build_nginx_idp_api_proxy_block)"
|
||
server_dns="$(nginx_docker_server_dns_block)"
|
||
|
||
if [[ "$ssl_type" == "none" ]]; then
|
||
nginx_write_conf "$conf" <<EOF
|
||
server {
|
||
listen 80;
|
||
listen [::]:80;
|
||
server_name ${domain};
|
||
client_max_body_size 50m;
|
||
${server_dns}
|
||
${idp_api_block}
|
||
location / {
|
||
${proxy_root}
|
||
proxy_http_version 1.1;
|
||
${nginx_proxy_headers}
|
||
}
|
||
}
|
||
EOF
|
||
else
|
||
ssl_block="$(nginx_ssl_directives "$domain" "$ssl_type")"
|
||
nginx_write_conf "$conf" <<EOF
|
||
${http_preamble}server {
|
||
listen 443 ssl http2;
|
||
listen [::]:443 ssl http2;
|
||
server_name ${domain};
|
||
${ssl_block}
|
||
client_max_body_size 50m;
|
||
${server_dns}
|
||
${idp_api_block}
|
||
location / {
|
||
${proxy_root}
|
||
proxy_http_version 1.1;
|
||
${nginx_proxy_headers}
|
||
}
|
||
}
|
||
EOF
|
||
fi
|
||
nginx_publish_config "fedcm-apex"
|
||
}
|
||
|
||
write_nginx_site_docs() {
|
||
local domain="$1"
|
||
local ssl_type="${2:-none}"
|
||
local conf http_preamble ssl_block proxy_root
|
||
conf="$(nginx_conf_target docs)"
|
||
proxy_root="$(render_proxy_root docs)"
|
||
http_preamble="$(nginx_http_server_preamble "$domain" "$ssl_type")"
|
||
|
||
if [[ "$ssl_type" == "none" ]]; then
|
||
nginx_write_conf "$conf" <<EOF
|
||
server {
|
||
listen 80;
|
||
listen [::]:80;
|
||
server_name ${domain};
|
||
location = / { return 302 /docs; }
|
||
location / {
|
||
${proxy_root}
|
||
proxy_http_version 1.1;
|
||
${nginx_proxy_headers}
|
||
}
|
||
}
|
||
EOF
|
||
else
|
||
ssl_block="$(nginx_ssl_directives "$domain" "$ssl_type")"
|
||
nginx_write_conf "$conf" <<EOF
|
||
${http_preamble}server {
|
||
listen 443 ssl http2;
|
||
listen [::]:443 ssl http2;
|
||
server_name ${domain};
|
||
${ssl_block}
|
||
location = / { return 302 /docs; }
|
||
location / {
|
||
${proxy_root}
|
||
proxy_http_version 1.1;
|
||
${nginx_proxy_headers}
|
||
}
|
||
}
|
||
EOF
|
||
fi
|
||
nginx_publish_config "docs"
|
||
}
|
||
|
||
write_nginx_site_ws() {
|
||
local domain="$1"
|
||
local ssl_type="${2:-none}"
|
||
local conf http_preamble ssl_block proxy_ws
|
||
conf="$(nginx_conf_target ws)"
|
||
proxy_ws="$(render_proxy_rewrite ws /ws)"
|
||
http_preamble="$(nginx_http_server_preamble "$domain" "$ssl_type")"
|
||
|
||
if [[ "$ssl_type" == "none" ]]; then
|
||
nginx_write_conf "$conf" <<EOF
|
||
server {
|
||
listen 80;
|
||
listen [::]:80;
|
||
server_name ${domain};
|
||
location / {
|
||
${proxy_ws}
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Upgrade \$http_upgrade;
|
||
proxy_set_header Connection "upgrade";
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_read_timeout 86400s;
|
||
}
|
||
}
|
||
EOF
|
||
else
|
||
ssl_block="$(nginx_ssl_directives "$domain" "$ssl_type")"
|
||
nginx_write_conf "$conf" <<EOF
|
||
${http_preamble}server {
|
||
listen 443 ssl http2;
|
||
listen [::]:443 ssl http2;
|
||
server_name ${domain};
|
||
${ssl_block}
|
||
location / {
|
||
${proxy_ws}
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Upgrade \$http_upgrade;
|
||
proxy_set_header Connection "upgrade";
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_read_timeout 86400s;
|
||
}
|
||
}
|
||
EOF
|
||
fi
|
||
nginx_publish_config "ws"
|
||
}
|
||
|
||
write_nginx_site_minio() {
|
||
local domain="$1"
|
||
local kind="$2"
|
||
local label="$3"
|
||
local ssl_type="${4:-none}"
|
||
local conf http_preamble ssl_block proxy_root
|
||
conf="$(nginx_conf_target "$label")"
|
||
proxy_root="$(render_proxy_root "$kind")"
|
||
http_preamble="$(nginx_http_server_preamble "$domain" "$ssl_type")"
|
||
|
||
if [[ "$ssl_type" == "none" ]]; then
|
||
nginx_write_conf "$conf" <<EOF
|
||
server {
|
||
listen 80;
|
||
listen [::]:80;
|
||
server_name ${domain};
|
||
client_max_body_size 1024m;
|
||
proxy_buffering off;
|
||
location / {
|
||
${proxy_root}
|
||
proxy_http_version 1.1;
|
||
${nginx_proxy_headers}
|
||
}
|
||
}
|
||
EOF
|
||
else
|
||
ssl_block="$(nginx_ssl_directives "$domain" "$ssl_type")"
|
||
nginx_write_conf "$conf" <<EOF
|
||
${http_preamble}server {
|
||
listen 443 ssl http2;
|
||
listen [::]:443 ssl http2;
|
||
server_name ${domain};
|
||
${ssl_block}
|
||
client_max_body_size 1024m;
|
||
proxy_buffering off;
|
||
location / {
|
||
${proxy_root}
|
||
proxy_http_version 1.1;
|
||
${nginx_proxy_headers}
|
||
}
|
||
}
|
||
EOF
|
||
fi
|
||
nginx_publish_config "$label"
|
||
}
|
||
|
||
remove_nginx_configs() {
|
||
log "Удаление конфигов Nginx IdP..."
|
||
rm -f "${LOCAL_CONF_DIR}/${NGINX_PREFIX}-"*.conf 2>/dev/null || true
|
||
if [[ "$NGINX_MODE" == "host" ]] || [[ "${FORCE_DOCKER_NGINX:-0}" -eq 1 ]]; then
|
||
run_root rm -f \
|
||
"${NGINX_ENABLED}/${NGINX_PREFIX}-"*.conf \
|
||
"${NGINX_AVAILABLE}/${NGINX_PREFIX}-"*.conf 2>/dev/null || true
|
||
run_root nginx -t >/dev/null 2>&1 && run_root systemctl reload nginx || true
|
||
fi
|
||
ok "Конфиги Nginx IdP удалены"
|
||
}
|
||
|
||
# Перед записью конфигов: docker-режим если lendry-id-nginx или FORCE_DOCKER / NGINX_MODE=docker в .env
|
||
ensure_nginx_mode_for_config_write() {
|
||
load_env
|
||
if [[ "${FORCE_DOCKER_NGINX:-0}" -eq 1 ]] \
|
||
|| [[ "$(env_get NGINX_MODE 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
|
||
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
|
||
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
|
||
}
|
||
|
||
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" <<EOF
|
||
{"version":"${SCRIPT_VERSION}","updated_at":"${ts}","domains":{"api":"${DOMAIN_API:-}","frontend":"${DOMAIN_FRONTEND:-}","docs":"${DOMAIN_DOCS:-}","ws":"${DOMAIN_WS:-}"}}
|
||
EOF
|
||
}
|
||
|
||
print_intranet_hint() {
|
||
load_env
|
||
[[ "$(env_get INSTALL_MODE local)" == "intranet" ]] || return 0
|
||
|
||
local server_ip
|
||
server_ip="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
|
||
[[ -z "$server_ip" ]] && server_ip="<IP-сервера>"
|
||
|
||
local apex_domain extra_apex=""
|
||
apex_domain="$(registrable_domain "${DOMAIN_FRONTEND:-$DOMAIN_API}")"
|
||
if [[ -n "$apex_domain" \
|
||
&& "$apex_domain" != "$DOMAIN_FRONTEND" \
|
||
&& "$apex_domain" != "$DOMAIN_API" \
|
||
&& "$apex_domain" != "${DOMAIN_DOCS:-}" ]]; then
|
||
extra_apex=" ${apex_domain}"
|
||
fi
|
||
|
||
echo -e " ${BOLD}Локальная сеть (DNS / hosts):${NC}"
|
||
echo " Пропишите во внутреннем DNS или C:\\Windows\\System32\\drivers\\etc\\hosts:"
|
||
echo " ${server_ip} ${DOMAIN_API} ${DOMAIN_FRONTEND} ${DOMAIN_DOCS}${extra_apex}"
|
||
if [[ -n "$extra_apex" ]]; then
|
||
echo " (${apex_domain} обязателен для входа через FedCM/One Tap из сторонних приложений —"
|
||
echo " браузер берёт /.well-known/web-identity именно с корневого домена)"
|
||
fi
|
||
echo ""
|
||
if [[ "$(env_get SSL_TYPE none)" == "selfsigned" ]]; then
|
||
echo " Сертификат: ${LOCAL_CERTS_DIR}/shared-intranet (общий для всех доменов IdP)"
|
||
echo " FedCM/One Tap: браузер открывает ${apex_domain:-корневой домен}/.well-known/web-identity —"
|
||
echo " примите сертификат для ${DOMAIN_FRONTEND}, ${DOMAIN_API}${extra_apex} и доменов OAuth-приложений."
|
||
echo " SAN OAuth-приложений подтягиваются автоматически из redirect_uri (файл oauth-redirect-sans.txt)."
|
||
echo " Надёжно: импортируйте fullchain.pem в «Доверенные корневые центры» на каждом ПК (Windows/macOS)."
|
||
if [[ -n "$(env_get EXTRA_SSL_SANS "")" ]]; then
|
||
echo " EXTRA_SSL_SANS (ручной доп.): $(env_get EXTRA_SSL_SANS "")"
|
||
fi
|
||
echo " После нового OAuth-клиента: ./install.sh --fix-all (или обновление SSL) пересоздаст сертификат."
|
||
echo ""
|
||
fi
|
||
if [[ "$(env_get LDAP_USE_HOST_NETWORK false)" == "true" ]]; then
|
||
echo " LDAP: контейнер ldap-auth использует сеть хоста (резолвит AD-имена как сервер)."
|
||
elif [[ -n "$(env_get LDAP_DNS_SERVERS "")" || -n "$(env_get LDAP_EXTRA_HOSTS "")" ]]; then
|
||
echo " LDAP: для ldap-auth настроены DNS/extra_hosts из .env"
|
||
else
|
||
echo " LDAP: если AD-имена не резолвятся — LDAP_USE_HOST_NETWORK=true или IP DC в LDAP_HOST."
|
||
fi
|
||
echo ""
|
||
}
|
||
|
||
print_summary() {
|
||
load_env
|
||
|
||
echo ""
|
||
echo -e "${GREEN}══════════════════════════════════════════════════════════════${NC}"
|
||
echo -e "${GREEN} Lendry ID — развёртывание завершено${NC}"
|
||
echo -e "${GREEN}══════════════════════════════════════════════════════════════${NC}"
|
||
echo ""
|
||
echo -e " ${BOLD}Режим:${NC} ${INSTALL_MODE:-local} | SSL: ${SSL_TYPE:-none} | Nginx: ${NGINX_MODE:-auto}"
|
||
echo -e " ${BOLD}API / Swagger:${NC} ${PUBLIC_API_URL}/docs"
|
||
echo -e " ${BOLD}Frontend:${NC} ${PUBLIC_FRONTEND_URL}"
|
||
echo -e " ${BOLD}Документация:${NC} ${PUBLIC_DOCS_URL}/docs"
|
||
echo -e " ${BOLD}WebSocket:${NC} ${PUBLIC_WS_URL}"
|
||
echo ""
|
||
print_intranet_hint
|
||
echo -e " ${BOLD}Управление:${NC} ./install.sh"
|
||
echo -e " ${BOLD}Смена доменов:${NC} ./install.sh --configure-domains"
|
||
echo -e " ${BOLD}Логи:${NC} docker compose logs -f api-gateway nginx"
|
||
echo -e " ${BOLD}Очистка Docker:${NC} ./install.sh --docker-prune"
|
||
echo -e " ${BOLD}С нуля:${NC} ./install.sh --fresh-install --yes"
|
||
echo ""
|
||
echo " Первый зарегистрированный пользователь станет супер-администратором."
|
||
echo " Секреты в .env — не коммитьте в git."
|
||
echo ""
|
||
|
||
if [[ "${DOCKER_SUGGESTED_GROUP:-}" == "1" ]]; then
|
||
warn "Docker без sudo: выполните newgrp docker или перелогиньтесь"
|
||
fi
|
||
}
|
||
|
||
refresh_host_nginx_configs() {
|
||
load_env
|
||
[[ "$(env_get NGINX_MODE docker)" == "host" ]] || return 0
|
||
[[ -n "$(env_get DOMAIN_API "")" ]] || return 0
|
||
[[ "$(env_get INSTALL_MODE local)" == "local" ]] && return 0
|
||
|
||
local ssl_type
|
||
ssl_type="$(env_get SSL_TYPE none)"
|
||
case "$ssl_type" in
|
||
none) write_nginx_http_only || warn "Не удалось записать HTTP-конфиги Nginx (нужен sudo?)" ;;
|
||
selfsigned)
|
||
generate_all_self_signed_certs
|
||
write_all_nginx_configs "selfsigned" || warn "Не удалось обновить Nginx (нужен sudo?)"
|
||
;;
|
||
custom)
|
||
install_custom_certificates
|
||
write_all_nginx_configs "custom" || warn "Не удалось обновить Nginx (нужен sudo?)"
|
||
;;
|
||
letsencrypt)
|
||
write_all_nginx_configs "letsencrypt" || warn "Не удалось обновить Nginx (нужен sudo?)"
|
||
;;
|
||
esac
|
||
}
|
||
|
||
check_local_upstream() {
|
||
local label="$1" url="$2"
|
||
if curl -fsS --max-time 3 "$url" >/dev/null 2>&1; then
|
||
echo -e " ${GREEN}✔${NC} ${label}: ${url}"
|
||
return 0
|
||
else
|
||
echo -e " ${RED}✘${NC} ${label}: ${url} — недоступен"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# Readiness api-gateway → sso-core по gRPC (/health не проверяет gRPC).
|
||
wait_for_api_grpc_ready() {
|
||
local attempt max="${1:-60}" code
|
||
for ((attempt = 1; attempt <= max; attempt++)); do
|
||
code="$(curl -fsS --max-time 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/health/ready 2>/dev/null || echo 000)"
|
||
if [[ "$code" == "200" ]]; then
|
||
ok "gRPC api-gateway → sso-core: готов (/health/ready)"
|
||
return 0
|
||
fi
|
||
sleep 2
|
||
done
|
||
warn "gRPC api-gateway → sso-core не готов (HTTP ${code:-000} на /health/ready)"
|
||
return 1
|
||
}
|
||
|
||
check_https_api_with_origin() {
|
||
local api_domain frontend_domain origin code
|
||
api_domain="$(env_get DOMAIN_API "")"
|
||
frontend_domain="$(env_get DOMAIN_FRONTEND "")"
|
||
[[ -n "$api_domain" && -n "$frontend_domain" ]] || return 0
|
||
[[ "$(env_get USE_NGINX_SSL false)" == "true" ]] || return 0
|
||
|
||
origin="https://${frontend_domain}"
|
||
code="$(curl -sk --max-time 5 -o /dev/null -w '%{http_code}' -H "Origin: ${origin}" "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=<jwt> ./install.sh --fix-all"
|
||
echo " (скопируйте lendry_access_token из DevTools → Application → Local Storage на sso)"
|
||
fi
|
||
|
||
return 0
|
||
}
|
||
|
||
# Диагностика типичных проблем: 502 на /idp-api, конфликт nginx, порты upstream.
|
||
diagnose_common_issues() {
|
||
load_env
|
||
local issues=0
|
||
|
||
echo ""
|
||
echo -e "${BOLD}Диагностика${NC}"
|
||
echo ""
|
||
|
||
if docker_nginx_container_running; then
|
||
if system_nginx_is_active \
|
||
&& [[ "$(env_get NGINX_MODE docker)" == "docker" ]]; then
|
||
echo -e " ${RED}✘${NC} Конфликт: системный Nginx на 80/443 и lendry-id-nginx одновременно — браузер может ходить не туда (502)"
|
||
echo " Решение: sudo systemctl stop nginx && ./install.sh --fix-all"
|
||
issues=$((issues + 1))
|
||
elif [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
|
||
echo -e " ${RED}✘${NC} Конфликт: NGINX_MODE=host, но запущен 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"
|
||
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 "$@"
|