#!/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.2.5" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$ROOT_DIR" NGINX_PREFIX="lendry-id" NGINX_AVAILABLE="/etc/nginx/sites-available" NGINX_ENABLED="/etc/nginx/sites-enabled" CERTBOT_WEBROOT="/var/www/certbot" LOCAL_CONF_DIR="${ROOT_DIR}/nginx/conf.d" LOCAL_CERTS_DIR="${ROOT_DIR}/nginx/certs" CUSTOM_CERT_TARGET_DIR="${LOCAL_CERTS_DIR}/custom" DEFAULT_CERT_UPLOAD_DIR="${LOCAL_CERTS_DIR}/upload" COMPOSE_OVERRIDE="${ROOT_DIR}/docker-compose.override.yml" STATE_FILE="${ROOT_DIR}/.idp-install.json" INFRA_SECRETS_FILE="${ROOT_DIR}/.idp-infra.secrets" NON_INTERACTIVE=0 FORCE_ENV="${FORCE_ENV:-0}" ACTION="" INSTALL_LOCAL=0 INSTALL_INTRANET=0 OFFLINE="${OFFLINE:-0}" NGINX_MODE="${NGINX_MODE:-auto}" SKIP_DOCKER_PRUNE="${SKIP_DOCKER_PRUNE:-0}" DOCKER_PRUNE_AGGRESSIVE="${DOCKER_PRUNE_AGGRESSIVE:-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="" RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' log() { echo -e "${CYAN}[IdP]${NC} $*"; } ok() { echo -e "${GREEN}[IdP]${NC} $*"; } warn() { echo -e "${YELLOW}[IdP]${NC} $*"; } fail() { echo -e "${RED}[IdP] ОШИБКА:${NC} $*" >&2; exit 1; } usage() { cat </dev/null 2>&1; } detect_os() { if [[ -f /etc/os-release ]]; then # shellcheck disable=SC1091 . /etc/os-release OS_ID="${ID:-unknown}" OS_VERSION_ID="${VERSION_ID:-}" else OS_ID="unknown" fi case "$OS_ID" in ubuntu | debian | linuxmint | pop) PKG_MANAGER="apt" ;; fedora | rhel | centos | rocky | almalinux) PKG_MANAGER="dnf" ;; opensuse* | sles) PKG_MANAGER="zypper" ;; *) PKG_MANAGER="unknown" ;; esac log "ОС: ${OS_ID:-unknown} ${OS_VERSION_ID:-} (пакеты: $PKG_MANAGER)" } run_root() { if [[ "$(id -u)" -eq 0 ]]; then "$@" elif has_cmd sudo; then sudo "$@" else fail "Нужны права root/sudo для: $*" fi } preflight_checks() { if [[ "$(uname -s)" != "Linux" ]]; then warn "ОС не Linux — Nginx будет запущен в Docker (профиль proxy). Убедитесь, что Docker Desktop установлен." NGINX_MODE="docker" fi [[ -f docker-compose.yml ]] || fail "Запустите скрипт из корня репозитория (рядом с docker-compose.yml)" mkdir -p "$LOCAL_CONF_DIR" "$LOCAL_CERTS_DIR" "$DEFAULT_CERT_UPLOAD_DIR" } detect_nginx_mode() { load_env if [[ -n "$(env_get NGINX_MODE "")" && "$(env_get NGINX_MODE auto)" != "auto" ]]; then NGINX_MODE="$(env_get NGINX_MODE auto)" fi if [[ "$NGINX_MODE" == "auto" ]]; then if [[ "$(uname -s)" == "Linux" ]] && { [[ -d /etc/nginx ]] || has_cmd nginx; }; then NGINX_MODE="host" else NGINX_MODE="docker" fi fi log "Режим Nginx: ${NGINX_MODE} (host=системный, docker=контейнер)" } port_in_use() { local port="$1" if has_cmd ss; then ss -tln 2>/dev/null | grep -qE ":${port}[[:space:]]" elif has_cmd netstat; then netstat -tln 2>/dev/null | grep -qE ":${port}[[:space:]]" else return 1 fi } host_nginx_available() { [[ -d /etc/nginx ]] || has_cmd nginx } system_nginx_is_active() { has_cmd systemctl && systemctl is-active --quiet nginx 2>/dev/null } lendry_host_nginx_configured() { [[ -f "${NGINX_ENABLED}/${NGINX_PREFIX}-frontend.conf" ]] \ || [[ -L "${NGINX_ENABLED}/${NGINX_PREFIX}-frontend.conf" ]] } 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 } prepare_compose_stack() { resolve_nginx_mode_and_ports write_compose_override load_env if [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then stop_docker_nginx fi } resolve_nginx_mode_and_ports() { load_env 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 && [[ "$NGINX_HTTP_PORT" == "80" ]]; 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 return 0 } # Заполняет глобальный массив COMPOSE_STACK_CMD для docker compose build_compose_stack_cmd() { COMPOSE_STACK_CMD=(compose --env-file .env) if compose_uses_proxy_profile; then COMPOSE_STACK_CMD+=(--profile proxy) fi } install_packages_apt() { run_root apt-get update -qq run_root apt-get install -y -qq ca-certificates curl git openssl gnupg lsb-release jq } install_packages_dnf() { run_root dnf install -y ca-certificates curl git openssl jq } install_base_packages() { log "Проверка базовых пакетов..." case "$PKG_MANAGER" in apt) install_packages_apt ;; dnf) install_packages_dnf ;; *) warn "Автоустановка пакетов для $OS_ID не поддерживается. Нужны: curl, git, openssl, jq" ;; esac } install_docker() { if has_cmd docker && docker info >/dev/null 2>&1; then ok "Docker: $(docker --version)" return fi if has_cmd docker && ! docker info >/dev/null 2>&1; then warn "Docker найден, но daemon недоступен. Пробуем запустить..." run_root systemctl start docker 2>/dev/null || true if docker info >/dev/null 2>&1; then ok "Docker daemon запущен" return fi fi if [[ "$OFFLINE" -eq 1 || "$INSTALL_INTRANET" -eq 1 ]]; then fail "Docker не установлен или не запущен. В локальной сети установите Docker вручную (offline-пакет / Docker Desktop) и повторите запуск." fi log "Установка Docker (нужен доступ в интернет)..." curl -fsSL https://get.docker.com | run_root sh run_root systemctl enable docker >/dev/null 2>&1 || true run_root systemctl start docker >/dev/null 2>&1 || true if [[ "$(id -u)" -ne 0 ]] && has_cmd sudo; then if ! groups "$USER" | grep -q docker; then run_root usermod -aG docker "$USER" || true warn "Пользователь $USER добавлен в группу docker. Может потребоваться перелогиниться." export DOCKER_SUGGESTED_GROUP=1 fi fi ok "Docker установлен" } check_docker_compose() { if docker compose version >/dev/null 2>&1; then ok "Docker Compose: $(docker compose version --short 2>/dev/null || docker compose version)" return fi fail "Docker Compose v2 не найден. Переустановите Docker." } docker_cmd() { if docker info >/dev/null 2>&1; then docker "$@" elif has_cmd sudo && sudo docker info >/dev/null 2>&1; then sudo docker "$@" else fail "Docker daemon недоступен. Запустите: sudo systemctl start docker" fi } configure_docker_build_env() { export DOCKER_BUILDKIT=1 export COMPOSE_DOCKER_CLI_BUILD=1 export BUILDKIT_PROGRESS="${BUILDKIT_PROGRESS:-auto}" export COMPOSE_PARALLEL_LIMIT="${COMPOSE_PARALLEL_LIMIT:-4}" } docker_disk_usage_brief() { docker_cmd system df 2>/dev/null | sed 's/^/ /' || true } docker_remove_stopped_project_containers() { local project cid project="$(compose_project_name)" while IFS= read -r cid; do [[ -z "$cid" ]] && continue docker_cmd rm -f "$cid" 2>/dev/null || true done < <(docker_cmd ps -aq --filter "label=com.docker.compose.project=${project}" --filter "status=exited" 2>/dev/null || true) } docker_prune_stale_project_images() { local project="$1" local aggressive="${2:-0}" local id repo while IFS= read -r id; do [[ -z "$id" ]] && continue if docker_cmd ps -a --filter "ancestor=${id}" -q 2>/dev/null | grep -q .; then continue fi docker_cmd rmi -f "$id" 2>/dev/null || true done < <(docker_cmd images --filter "label=com.docker.compose.project=${project}" -q 2>/dev/null | sort -u || true) if [[ "$aggressive" == "1" ]]; then while IFS= read -r repo id; do [[ -z "$id" || "$repo" == "" ]] && continue case "$repo" in lendry-id-* | idservice-* | "${project}"-*) if ! docker_cmd ps -a --filter "ancestor=${id}" -q 2>/dev/null | grep -q .; then docker_cmd rmi -f "$id" 2>/dev/null || true fi ;; esac done < <(docker_cmd images --format '{{.Repository}} {{.ID}}' 2>/dev/null || true) fi } docker_prune_before_rebuild() { local aggressive="${1:-0}" log "Очистка Docker перед пересборкой (volumes данных не удаляются)..." build_compose_stack_cmd if [[ "$aggressive" == "1" ]]; then log " Режим aggressive: остановка стека и удаление orphan-контейнеров..." docker_cmd "${COMPOSE_STACK_CMD[@]}" down --remove-orphans 2>/dev/null || true else log " Удаление остановленных контейнеров проекта..." docker_remove_stopped_project_containers docker_cmd container prune -f --filter "until=24h" 2>/dev/null || true fi log " Удаление dangling-образов и старого build cache..." docker_cmd image prune -f 2>/dev/null || true if docker_cmd builder prune --help 2>/dev/null | grep -q 'until='; then docker_cmd builder prune -f --filter "until=168h" 2>/dev/null || \ docker_cmd builder prune -f 2>/dev/null || true else docker_cmd builder prune -f 2>/dev/null || true fi log " Удаление неиспользуемых образов проекта..." docker_prune_stale_project_images "$(compose_project_name)" "$aggressive" ok "Docker очищен перед сборкой" docker_disk_usage_brief } docker_prune_after_rebuild() { docker_cmd image prune -f 2>/dev/null || true } compose_build_parallel_flags() { COMPOSE_BUILD_PARALLEL=() if docker_cmd compose build --help 2>/dev/null | grep -q -- '--parallel'; then COMPOSE_BUILD_PARALLEL=(--parallel) fi } compose_build_and_up() { local -a services=() if [[ $# -gt 0 ]]; then services=("$@") fi configure_docker_build_env build_compose_stack_cmd compose_build_parallel_flags 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 if [[ "${SKIP_DOCKER_PRUNE:-0}" -ne 1 ]]; then docker_prune_after_rebuild fi } action_docker_prune() { load_env prepare_compose_stack SKIP_DOCKER_PRUNE=0 docker_prune_before_rebuild "${DOCKER_PRUNE_AGGRESSIVE:-1}" ok "Очистка Docker завершена" } install_nginx_host() { if has_cmd nginx; then ok "Nginx уже установлен" return fi log "Установка Nginx..." case "$PKG_MANAGER" in apt) run_root apt-get install -y -qq nginx ;; dnf) run_root dnf install -y nginx ;; *) fail "Автоустановка Nginx для $OS_ID не поддерживается. Установите nginx вручную или используйте --nginx-mode docker" ;; esac run_root systemctl enable nginx >/dev/null 2>&1 || true ok "Nginx установлен" } install_certbot() { if has_cmd certbot; then ok "Certbot уже установлен" return fi if [[ "$OFFLINE" -eq 1 || "$INSTALL_INTRANET" -eq 1 ]]; then fail "Certbot недоступен offline. Для локальной сети используйте --ssl none или --ssl selfsigned" fi log "Установка Certbot..." case "$PKG_MANAGER" in apt) run_root apt-get install -y -qq certbot python3-certbot-nginx ;; dnf) run_root dnf install -y certbot ;; *) fail "Автоустановка Certbot для $OS_ID не поддерживается" ;; esac run_root mkdir -p "$CERTBOT_WEBROOT" ok "Certbot установлен" } install_nginx_certbot() { if [[ "$NGINX_MODE" == "docker" ]]; then ok "Nginx в Docker — установка системного nginx не требуется" return fi install_nginx_host } setup_firewall() { if ! has_cmd ufw; then warn "UFW не найден — пропускаем настройку фаервола" return fi if ! run_root ufw status 2>/dev/null | grep -q "Status: active"; then warn "UFW не активен — откройте порты 80 и 443 вручную при необходимости" return fi log "Настройка UFW (80, 443, SSH)..." run_root ufw allow 22/tcp >/dev/null 2>&1 || true run_root ufw allow 80/tcp >/dev/null 2>&1 || true run_root ufw allow 443/tcp >/dev/null 2>&1 || true ok "Порты 80 и 443 разрешены в UFW" } rand_secret() { local length="${1:-48}" if has_cmd openssl; then openssl rand -base64 64 | tr -dc 'A-Za-z0-9' | head -c "$length" else tr -dc 'A-Za-z0-9' >.env else printf '%s=%s\n' "$key" "$value" >.env fi chmod 600 .env 2>/dev/null || true } derive_public_urls() { local ssl_type="${1:-none}" if [[ "$INSTALL_LOCAL" -eq 1 ]]; then if [[ -z "${DOMAIN_API:-}" && -z "${DOMAIN_FRONTEND:-}" ]]; then PUBLIC_API_URL="http://localhost:3000" PUBLIC_FRONTEND_URL="http://localhost:3002" PUBLIC_DOCS_URL="http://localhost:3003" PUBLIC_WS_URL="ws://localhost:8085/ws" MINIO_PUBLIC_ENDPOINT="${MINIO_PUBLIC_ENDPOINT:-localhost:9000}" MINIO_USE_SSL="false" USE_NGINX_SSL="false" SSL_TYPE="none" INSTALL_MODE="local" return fi fi local scheme="http" [[ "$ssl_type" == "letsencrypt" || "$ssl_type" == "selfsigned" || "$ssl_type" == "custom" ]] && scheme="https" PUBLIC_API_URL="${scheme}://${DOMAIN_API}" PUBLIC_FRONTEND_URL="${scheme}://${DOMAIN_FRONTEND}" PUBLIC_DOCS_URL="${scheme}://${DOMAIN_DOCS}" if [[ -n "${DOMAIN_WS:-}" && "${DOMAIN_WS}" != "${DOMAIN_API}" ]]; then if [[ "$scheme" == "https" ]]; then PUBLIC_WS_URL="wss://${DOMAIN_WS}/ws" else PUBLIC_WS_URL="ws://${DOMAIN_WS}/ws" fi else if [[ "$scheme" == "https" ]]; then PUBLIC_WS_URL="wss://${DOMAIN_API}/ws" else PUBLIC_WS_URL="ws://${DOMAIN_API}/ws" fi fi if [[ -n "${DOMAIN_MINIO:-}" ]]; then MINIO_PUBLIC_ENDPOINT="${DOMAIN_MINIO}" MINIO_USE_SSL=$([[ "$scheme" == "https" ]] && echo true || echo false) fi if [[ "$ssl_type" == "selfsigned" && -n "${DOMAIN_FRONTEND:-}" ]]; then apply_same_origin_api_proxy_urls "$scheme" 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 } choose_ssl_type_interactive() { local default="${1:-none}" if [[ -n "$CLI_SSL_TYPE" ]]; then SSL_TYPE="$CLI_SSL_TYPE" return fi if [[ "$NON_INTERACTIVE" -eq 1 ]]; then if [[ "$INSTALL_INTRANET" -eq 1 ]]; then SSL_TYPE="${default:-none}" else SSL_TYPE="${default:-letsencrypt}" fi return fi echo "" echo -e "${BOLD}Тип SSL / доступ:${NC}" echo " 1) HTTP — локальная сеть, без сертификатов (http://api.idpmvk.lpr)" echo " 2) HTTPS самоподписанный — локальная сеть, браузер покажет предупреждение" echo " 3) HTTPS Let's Encrypt — нужен интернет и публичный DNS" echo " 4) HTTPS свои сертификаты — загрузите fullchain.pem и privkey.pem в папку на сервере" echo "" local ssl_choice if [[ "$INSTALL_INTRANET" -eq 1 ]]; then read -r -p "Выберите [1-4] [1]: " ssl_choice ssl_choice="${ssl_choice:-1}" else read -r -p "Выберите [1-4] [3]: " ssl_choice ssl_choice="${ssl_choice:-3}" fi case "$ssl_choice" in 1) SSL_TYPE="none" ;; 2) SSL_TYPE="selfsigned" ;; 3) SSL_TYPE="letsencrypt" ;; 4) SSL_TYPE="custom" ;; *) SSL_TYPE="$default" ;; esac } configure_domains_interactive() { local existing_api existing_frontend existing_docs existing_ws existing_email if [[ "$(env_get INSTALL_MODE "")" == "intranet" ]]; then INSTALL_INTRANET=1 fi existing_api="$(normalize_domain "$(env_get DOMAIN_API "")")" existing_frontend="$(normalize_domain "$(env_get DOMAIN_FRONTEND "")")" existing_docs="$(normalize_domain "$(env_get DOMAIN_DOCS "")")" existing_ws="$(normalize_domain "$(env_get DOMAIN_WS "")")" existing_email="$(env_get CERTBOT_EMAIL "")" local default_api="${CLI_DOMAIN_API:-$existing_api}" local default_frontend="${CLI_DOMAIN_FRONTEND:-$existing_frontend}" local default_docs="${CLI_DOMAIN_DOCS:-$existing_docs}" local default_ws="${CLI_DOMAIN_WS:-$existing_ws}" local default_minio="${CLI_DOMAIN_MINIO:-$(env_get DOMAIN_MINIO "")}" local default_minio_console="${CLI_DOMAIN_MINIO_CONSOLE:-$(env_get DOMAIN_MINIO_CONSOLE "")}" local default_email="${CLI_EMAIL:-$existing_email}" if [[ "$INSTALL_LOCAL" -eq 1 ]]; then DOMAIN_API="" DOMAIN_FRONTEND="" DOMAIN_DOCS="" DOMAIN_WS="" DOMAIN_MINIO="" DOMAIN_MINIO_CONSOLE="" derive_public_urls "none" return fi echo "" echo -e "${BOLD}Настройка доменов${NC}" if [[ "$INSTALL_INTRANET" -eq 1 ]]; then echo "Локальная сеть: укажите внутренние DNS-имена (например idpmvk.lpr)." echo "Пропишите A-записи во внутреннем DNS или файл hosts на клиентах." echo "Доступ из интернета и Let's Encrypt не требуются." else echo "Укажите домен для каждого сервиса (A-запись → IP сервера)." fi echo "Enter — оставить текущее значение. localhost без Nginx: ./install.sh --local" echo "" if [[ "$INSTALL_INTRANET" -eq 1 ]]; then if [[ -z "$default_api" ]]; then default_api="api.idpmvk.lpr"; fi if [[ -z "$default_frontend" ]]; then default_frontend="id.idpmvk.lpr"; fi if [[ -z "$default_docs" ]]; then default_docs="docs.idpmvk.lpr"; fi else if [[ -z "$default_api" ]]; then default_api="api.example.com"; fi if [[ -z "$default_frontend" ]]; then default_frontend="id.example.com"; fi if [[ -z "$default_docs" ]]; then default_docs="docs.example.com"; fi fi prompt DOMAIN_API "Домен API / Swagger" "$default_api" prompt DOMAIN_FRONTEND "Домен Frontend (UI)" "$default_frontend" prompt DOMAIN_DOCS "Домен документации" "$default_docs" prompt_optional DOMAIN_WS "Домен WebSocket (пусто = ${DOMAIN_API}/ws)" "$default_ws" prompt_optional DOMAIN_MINIO "Домен MinIO S3 (опционально)" "$default_minio" prompt_optional DOMAIN_MINIO_CONSOLE "Домен MinIO Console (опционально)" "$default_minio_console" DOMAIN_API="$(normalize_domain "$DOMAIN_API")" DOMAIN_FRONTEND="$(normalize_domain "$DOMAIN_FRONTEND")" DOMAIN_DOCS="$(normalize_domain "$DOMAIN_DOCS")" DOMAIN_WS="$(normalize_domain "$DOMAIN_WS")" DOMAIN_MINIO="$(normalize_domain "$DOMAIN_MINIO")" DOMAIN_MINIO_CONSOLE="$(normalize_domain "$DOMAIN_MINIO_CONSOLE")" for d in "$DOMAIN_API" "$DOMAIN_FRONTEND" "$DOMAIN_DOCS" "$DOMAIN_WS" "$DOMAIN_MINIO" "$DOMAIN_MINIO_CONSOLE"; do [[ -z "$d" ]] && continue validate_domain "$d" || fail "Некорректный домен: $d" done [[ -n "$DOMAIN_API" && -n "$DOMAIN_FRONTEND" && -n "$DOMAIN_DOCS" ]] \ || fail "Домены API, Frontend и Docs обязательны (или используйте --local)" choose_ssl_type_interactive "$(env_get SSL_TYPE none)" case "$SSL_TYPE" in none | selfsigned | custom | letsencrypt) ;; *) fail "Некорректный SSL_TYPE: $SSL_TYPE (none | selfsigned | custom | letsencrypt)" ;; esac if [[ "$SSL_TYPE" == "custom" ]]; then prompt_custom_cert_settings fi if [[ "$SSL_TYPE" == "letsencrypt" ]]; then if [[ "$INSTALL_INTRANET" -eq 1 && "$NON_INTERACTIVE" -ne 1 ]]; then warn "Let's Encrypt требует доступ из интернета. Для чистой локальной сети выберите HTTP или selfsigned." fi prompt CERTBOT_EMAIL "Email для Let's Encrypt" "${default_email:-admin@${DOMAIN_FRONTEND}}" [[ -n "$CERTBOT_EMAIL" ]] || fail "Email обязателен для Let's Encrypt" else CERTBOT_EMAIL="" fi derive_public_urls "$SSL_TYPE" } write_env_secrets_if_missing() { [[ -f .env && "$FORCE_ENV" != "1" ]] && return local pg_pass rmq_pass minio_pass jwt_access jwt_refresh data_key pg_pass="$(rand_secret 32)" rmq_pass="$(rand_secret 32)" minio_pass="$(rand_secret 24)" jwt_access="$(rand_secret 64)" jwt_refresh="$(rand_secret 64)" data_key="$(rand_secret 48)" if [[ ! -f .env ]]; then cp .env.example .env 2>/dev/null || touch .env fi load_infra_secrets_into_env if docker_volume_exists postgres_data; then if [[ -n "$(env_get POSTGRES_PASSWORD "")" ]]; then warn "Volume PostgreSQL уже существует — POSTGRES_PASSWORD в .env не меняем" else fail "Volume PostgreSQL существует, но POSTGRES_PASSWORD не найден. Восстановите .env или выполните: ./install.sh --reset-db" fi pg_pass="$(env_get POSTGRES_PASSWORD "$pg_pass")" fi if docker_volume_exists rabbitmq_data; then warn "Volume RabbitMQ уже существует — RABBITMQ_DEFAULT_PASS не меняем" rmq_pass="$(env_get RABBITMQ_DEFAULT_PASS "$rmq_pass")" fi if docker_volume_exists minio_data; then warn "Volume MinIO уже существует — MINIO_ROOT_PASSWORD не меняем" minio_pass="$(env_get MINIO_ROOT_PASSWORD "$minio_pass")" fi local need_secrets=0 if [[ "$FORCE_ENV" == "1" ]]; then need_secrets=1 elif [[ "$(env_get POSTGRES_PASSWORD change-me-postgres)" == "change-me-postgres" ]]; then need_secrets=1 elif [[ "$(env_get JWT_ACCESS_SECRET change-me-access-secret)" == "change-me-access-secret" ]]; then need_secrets=1 fi if [[ "$need_secrets" -eq 1 ]]; then env_set POSTGRES_PASSWORD "$pg_pass" env_set RABBITMQ_DEFAULT_PASS "$rmq_pass" env_set MINIO_ROOT_PASSWORD "$minio_pass" env_set JWT_ACCESS_SECRET "$jwt_access" env_set JWT_REFRESH_SECRET "$jwt_refresh" env_set DATA_ENCRYPTION_KEY "$data_key" save_infra_secrets ok "Секреты сгенерированы / синхронизированы" fi } compose_project_name() { load_env echo "${COMPOSE_PROJECT_NAME:-lendry-id}" } docker_volume_name() { echo "$(compose_project_name)_$1" } docker_volume_exists() { docker volume inspect "$(docker_volume_name "$1")" >/dev/null 2>&1 } save_infra_secrets() { load_env cat >"$INFRA_SECRETS_FILE" </dev/null || true } load_infra_secrets_into_env() { [[ -f "$INFRA_SECRETS_FILE" ]] || return 0 # shellcheck disable=SC1091 set -a && source "$INFRA_SECRETS_FILE" && set +a [[ -n "${POSTGRES_PASSWORD:-}" ]] && env_set POSTGRES_PASSWORD "$POSTGRES_PASSWORD" [[ -n "${RABBITMQ_DEFAULT_PASS:-}" ]] && env_set RABBITMQ_DEFAULT_PASS "$RABBITMQ_DEFAULT_PASS" [[ -n "${MINIO_ROOT_PASSWORD:-}" ]] && env_set MINIO_ROOT_PASSWORD "$MINIO_ROOT_PASSWORD" } verify_postgres_auth() { load_env load_infra_secrets_into_env [[ -f .env ]] || return 0 docker_cmd compose ps postgres 2>/dev/null | grep -q postgres || return 0 local user pass db user="$(env_get POSTGRES_USER lendry)" pass="$(env_get POSTGRES_PASSWORD "")" db="$(env_get POSTGRES_DB lendry_id)" [[ -n "$pass" ]] || return 0 if docker_cmd compose exec -T -e PGPASSWORD="$pass" postgres \ psql -U "$user" -d "$db" -c 'SELECT 1' >/dev/null 2>&1; then ok "PostgreSQL: пароль из .env подходит" save_infra_secrets return 0 fi echo "" fail "PostgreSQL: пароль в .env не совпадает с данными в volume (ошибка P1000). Это происходит после FORCE_ENV=1 или смены POSTGRES_PASSWORD при существующей БД. Решения: 1) Сбросить БД (удалит всех пользователей и данные): ./install.sh --reset-db --yes && ./install.sh --restart 2) Вернуть старый POSTGRES_PASSWORD в .env (если сохранили) 3) Если есть файл ${INFRA_SECRETS_FILE} — скопируйте пароль оттуда в .env" } action_reset_db() { load_env local vol vol="$(docker_volume_name postgres_data)" if [[ "$NON_INTERACTIVE" -ne 1 ]]; then echo "" warn "Будет удалён volume ${vol} — все пользователи, сессии и настройки в PostgreSQL пропадут." read -r -p "Продолжить? [y/N]: " confirm [[ "$confirm" =~ ^[Yy]$ ]] || fail "Отменено" fi log "Остановка сервисов..." docker_cmd compose --env-file .env --profile proxy down 2>/dev/null || docker_cmd compose --env-file .env down 2>/dev/null || true log "Удаление ${vol}..." if docker volume inspect "$vol" >/dev/null 2>&1; then docker volume rm "$vol" || fail "Не удалось удалить ${vol}" else warn "Volume ${vol} не найден — возможно, уже удалён" fi ok "PostgreSQL volume удалён. PostgreSQL создастся заново с паролем из .env." save_infra_secrets } persist_domain_env() { write_env_secrets_if_missing env_set INSTALL_MODE "${INSTALL_MODE:-local}" env_set NGINX_MODE "${NGINX_MODE:-auto}" env_set SSL_TYPE "${SSL_TYPE:-none}" env_set DOMAIN_API "${DOMAIN_API:-}" env_set DOMAIN_FRONTEND "${DOMAIN_FRONTEND:-}" env_set DOMAIN_DOCS "${DOMAIN_DOCS:-}" env_set DOMAIN_WS "${DOMAIN_WS:-}" env_set DOMAIN_MINIO "${DOMAIN_MINIO:-}" env_set DOMAIN_MINIO_CONSOLE "${DOMAIN_MINIO_CONSOLE:-}" env_set PUBLIC_API_URL "${PUBLIC_API_URL}" env_set PUBLIC_FRONTEND_URL "${PUBLIC_FRONTEND_URL}" env_set PUBLIC_DOCS_URL "${PUBLIC_DOCS_URL}" env_set PUBLIC_WS_URL "${PUBLIC_WS_URL}" env_set USE_NGINX_SSL "${USE_NGINX_SSL:-false}" env_set CERTBOT_EMAIL "${CERTBOT_EMAIL:-}" env_set SSL_CERT_DIR "${SSL_CERT_DIR:-$(env_get SSL_CERT_DIR "")}" env_set SSL_CERT_FULLCHAIN "${SSL_CERT_FULLCHAIN:-$(env_get SSL_CERT_FULLCHAIN "")}" env_set SSL_CERT_KEY "${SSL_CERT_KEY:-$(env_get SSL_CERT_KEY "")}" env_set MINIO_PUBLIC_ENDPOINT "${MINIO_PUBLIC_ENDPOINT:-localhost:9000}" env_set MINIO_USE_SSL "${MINIO_USE_SSL:-false}" if [[ "$(env_get INSTALL_MODE local)" == "intranet" && -z "$(env_get LDAP_USE_HOST_NETWORK "")" ]]; then env_set LDAP_USE_HOST_NETWORK "true" fi env_set LDAP_USE_HOST_NETWORK "$(env_get LDAP_USE_HOST_NETWORK false)" env_set LDAP_DNS_SERVERS "$(env_get LDAP_DNS_SERVERS "")" env_set LDAP_DNS_SEARCH "$(env_get LDAP_DNS_SEARCH "")" env_set LDAP_EXTRA_HOSTS "$(env_get LDAP_EXTRA_HOSTS "")" ok "Файл .env обновлён" derive_ldap_dns_settings } derive_ldap_dns_settings() { load_env local extra dns first ip extra="$(env_get LDAP_EXTRA_HOSTS "")" [[ -n "$extra" ]] || return 0 dns="$(env_get LDAP_DNS_SERVERS "")" if [[ -z "$dns" ]]; then first="${extra%%,*}" ip="${first##*:}" ip="$(echo "$ip" | xargs)" if [[ -n "$ip" ]]; then env_set LDAP_DNS_SERVERS "$ip" log "LDAP_DNS_SERVERS=${ip} (из LDAP_EXTRA_HOSTS)" fi fi } sync_ldap_system_hosts() { load_env local extra extra="$(env_get LDAP_EXTRA_HOSTS "")" [[ -n "$extra" ]] || return 0 log "LDAP: запись имён DC в /etc/hosts..." local entry host ip IFS=',' read -ra _entries <<< "$extra" for entry in "${_entries[@]}"; do entry="$(echo "$entry" | xargs)" [[ "$entry" == *:* ]] || continue host="${entry%%:*}" host="$(echo "$host" | xargs)" ip="${entry##*:}" ip="$(echo "$ip" | xargs)" [[ -n "$host" && -n "$ip" ]] || continue if run_root grep -qE "[[:space:]]${host}([[:space:]]|$)" /etc/hosts 2>/dev/null; then continue fi run_root sh -c "printf '%s %s\n' '${ip}' '${host}' >> /etc/hosts" || warn "Не удалось добавить ${host} в /etc/hosts (нужен sudo?)" done } write_compose_override() { derive_ldap_dns_settings sync_ldap_system_hosts resolve_nginx_mode_and_ports load_env local mode="${NGINX_MODE:-$(env_get NGINX_MODE host)}" local has_domains install_mode has_domains="$(env_get DOMAIN_API "")" install_mode="$(env_get INSTALL_MODE local)" local nginx_fragment="" ldap_fragment="" body="" if [[ -n "$has_domains" && "$install_mode" != "local" && "$mode" == "docker" ]]; then local http_port https_port http_port="$(env_get NGINX_HTTP_PORT 80)" https_port="$(env_get NGINX_HTTPS_PORT 443)" log "Настройка docker-compose override (Nginx: ${mode})..." nginx_fragment="$(cat <"$COMPOSE_OVERRIDE" </dev/null | grep -q "127.0.0.1:${host_port}" || \ docker_cmd port "$container" 2>/dev/null | grep -q ":${host_port}->" } ensure_host_upstreams() { load_env [[ "$(env_get NGINX_MODE docker)" == "host" ]] || return 0 [[ -n "$(env_get DOMAIN_API "")" ]] || return 0 [[ "$(env_get INSTALL_MODE local)" == "local" ]] && return 0 local missing=0 for spec in "3000:API" "3002:Frontend" "3003:Docs"; do local port="${spec%%:*}" if ! port_in_use "$port"; then warn "Порт ${port} (${spec#*:}) не слушается на хосте" missing=1 fi done if [[ "$missing" -eq 1 ]]; then warn "Проброс портов не применён — пересоздаём edge-сервисы (NGINX_MODE=host)" recreate_host_port_services local attempt=0 while [[ $attempt -lt 12 ]]; do missing=0 for spec in "3000:API" "3002:Frontend" "3003:Docs"; do local port="${spec%%:*}" if ! port_in_use "$port"; then missing=1 fi done [[ "$missing" -eq 0 ]] && break attempt=$((attempt + 1)) sleep 5 done for spec in "3000:API" "3002:Frontend" "3003:Docs"; do local port="${spec%%:*}" if ! port_in_use "$port"; then echo "" warn "Диагностика:" docker_cmd ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' 2>/dev/null | sed 's/^/ /' || true docker_cmd port lendry-id-frontend 2>/dev/null | sed 's/^/ frontend: /' || echo " frontend: порты не опубликованы" echo "" fail "Порт ${port} недоступен. Проверьте: grep NGINX_MODE .env && cat docker-compose.override.yml && docker compose logs frontend" fi done ok "Порты 3000/3002/3003 доступны на localhost" fi } nginx_upstream() { case "$1" in api) [[ "$NGINX_MODE" == "docker" ]] && echo "http://api-gateway:3000" || echo "http://127.0.0.1:3000" ;; 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 } 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 (IFS=,; echo "${sans[*]}") } 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 } 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 и privkey.pem / 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)" \ || fail "Не найден файл сертификата в ${SSL_CERT_DIR}. Положите fullchain.pem или cert.pem, либо укажите --cert-fullchain" src_key="$(find_custom_cert_file "$SSL_CERT_DIR" "$SSL_CERT_KEY" \ privkey.pem key.pem server.key)" \ || fail "Не найден файл ключа в ${SSL_CERT_DIR}. Положите privkey.pem или 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 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" ]]; 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 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_read_timeout 300s; proxy_connect_timeout 75s;" 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 </dev/null || true if [[ "$NGINX_MODE" == "host" ]]; 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 удалены" } write_all_nginx_configs() { load_env 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" 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" 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}" 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" 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 if [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then stop_docker_nginx fi 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 минут при первом запуске)..." 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_service "frontend" 60 wait_for_service "docs" 60 if compose_uses_proxy_profile; then wait_for_service "nginx" 30 fi } save_install_state() { local ts ts="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" cat >"$STATE_FILE" </dev/null 2>&1; then echo -e " ${GREEN}✔${NC} ${label}: ${url}" else echo -e " ${RED}✘${NC} ${label}: ${url} — недоступен" fi } 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" "http://127.0.0.1:3000/health" 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" 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 "" 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-proxy" 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-proxy" 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 setup_nginx_proxy deploy_stack save_install_state print_summary } 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)" 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" ok "Свои сертификаты переустановлены из $(env_get SSL_CERT_DIR -)" ;; none) fail "SSL отключён (SSL_TYPE=none). Выберите --ssl selfsigned, custom или letsencrypt." ;; 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/4: остановка Docker Nginx..." stop_docker_nginx log "Шаг 2/4: compose override (LDAP / Nginx)..." write_compose_override log "Шаг 3/4: пересоздание api-gateway, frontend, docs, media-ws..." compose_build_and_up "${HOST_EDGE_SERVICES[@]}" ensure_host_upstreams log "Шаг 4/4: конфиги системного Nginx..." refresh_host_nginx_configs apply_intranet_selfsigned_runtime log "Шаг 5/5: пересоздание ldap-auth и sso-core..." compose_build_and_up ldap-auth sso-core ok "Прокси исправлен — проверьте: curl -I http://127.0.0.1:3002" 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 " 2) Настроить / изменить домены и Nginx + SSL" echo " 3) Обновить SSL-сертификаты (selfsigned / custom / Let's Encrypt)" echo " 4) Перезапуск / пересборка контейнеров (+ очистка Docker)" echo " 5) Статус сервисов" echo " 6) Удалить конфиги Nginx IdP" echo " 7) Сброс PostgreSQL (если ошибка P1000 / пароль БД)" echo " 8) Очистить Docker (образы/кеш, без пересборки)" echo " 0) Выход" echo "" local choice read -r -p "Выберите пункт [0-8]: " choice case "$choice" in 1) NON_INTERACTIVE=0; action_full_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 ;; 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 ;; configure-domains) action_configure_domains ;; renew-ssl) action_renew_ssl ;; restart) action_restart ;; docker-prune) action_docker_prune ;; fix-proxy) action_fix_host_proxy ;; 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 "$@"