#!/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.1.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" COMPOSE_OVERRIDE="${ROOT_DIR}/docker-compose.override.yml" STATE_FILE="${ROOT_DIR}/.idp-install.json" NON_INTERACTIVE=0 FORCE_ENV="${FORCE_ENV:-0}" ACTION="" INSTALL_LOCAL=0 INSTALL_INTRANET=0 OFFLINE="${OFFLINE:-0}" NGINX_MODE="${NGINX_MODE:-auto}" CLI_DOMAIN_API="" CLI_DOMAIN_FRONTEND="" CLI_DOMAIN_DOCS="" CLI_DOMAIN_WS="" CLI_DOMAIN_MINIO="" CLI_DOMAIN_MINIO_CONSOLE="" CLI_EMAIL="" CLI_SSL_TYPE="" 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" } 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=контейнер)" } 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 } 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" ]] && 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 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" ]]; then INSTALL_MODE="intranet" elif [[ -n "${DOMAIN_API:-}" ]]; then INSTALL_MODE="production" else INSTALL_MODE="local" 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 "" local ssl_choice if [[ "$INSTALL_INTRANET" -eq 1 ]]; then read -r -p "Выберите [1-3] [1]: " ssl_choice ssl_choice="${ssl_choice:-1}" else read -r -p "Выберите [1-3] [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" ;; *) 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 | letsencrypt) ;; *) fail "Некорректный SSL_TYPE: $SSL_TYPE (none | selfsigned | letsencrypt)" ;; esac 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 if [[ "$FORCE_ENV" == "1" ]] || [[ "$(env_get POSTGRES_PASSWORD change-me-postgres)" == "change-me-postgres" ]]; 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" ok "Секреты сгенерированы" fi } 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 MINIO_PUBLIC_ENDPOINT "${MINIO_PUBLIC_ENDPOINT:-localhost:9000}" env_set MINIO_USE_SSL "${MINIO_USE_SSL:-false}" ok "Файл .env обновлён" } write_compose_override() { load_env local mode="${NGINX_MODE:-$(env_get NGINX_MODE auto)}" local has_domains has_domains="$(env_get DOMAIN_API "")" if [[ -z "$has_domains" || "$(env_get INSTALL_MODE local)" == "local" ]]; then [[ -f "$COMPOSE_OVERRIDE" ]] && rm -f "$COMPOSE_OVERRIDE" return fi log "Настройка docker-compose override (Nginx: ${mode})..." local minio_block="" if [[ "$mode" == "host" && ( -n "$(env_get DOMAIN_MINIO "")" || -n "$(env_get DOMAIN_MINIO_CONSOLE "")" ) ]]; then minio_block=" minio: ports: !reset - \"127.0.0.1:9000:9000\" - \"127.0.0.1:9001:9001\"" else minio_block=" minio: ports: !reset []" fi if [[ "$mode" == "docker" ]]; then cat >"$COMPOSE_OVERRIDE" <"$COMPOSE_OVERRIDE" </dev/null || \ openssl req -x509 -nodes -days 825 -newkey rsa:2048 \ -keyout "${cert_dir}/privkey.pem" \ -out "${cert_dir}/fullchain.pem" \ -subj "/CN=${domain}/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() { load_env generate_self_signed_cert "$DOMAIN_API" generate_self_signed_cert "$DOMAIN_FRONTEND" generate_self_signed_cert "$DOMAIN_DOCS" if [[ -n "${DOMAIN_WS:-}" && "${DOMAIN_WS}" != "${DOMAIN_API}" ]]; then generate_self_signed_cert "$DOMAIN_WS" fi [[ -n "${DOMAIN_MINIO:-}" ]] && generate_self_signed_cert "$DOMAIN_MINIO" [[ -n "${DOMAIN_MINIO_CONSOLE:-}" ]] && generate_self_signed_cert "$DOMAIN_MINIO_CONSOLE" ok "Самоподписанные сертификаты созданы в ${LOCAL_CERTS_DIR}" } 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 run_root nginx -t run_root systemctl reload nginx 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 detect_nginx_mode env_set NGINX_MODE "$NGINX_MODE" 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 в доверенные на клиентах или примите предупреждение браузера" ;; 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() { export DOCKER_BUILDKIT=1 export COMPOSE_DOCKER_CLI_BUILD=1 write_compose_override load_env local compose_cmd=(compose --env-file .env) if [[ -n "$(env_get DOMAIN_API "")" && "$(env_get INSTALL_MODE local)" != "local" ]]; then compose_cmd+=(--profile proxy) fi log "Сборка и запуск контейнеров (10–20 минут при первом запуске)..." docker_cmd "${compose_cmd[@]}" up -d --build wait_for_service "api-gateway" 90 wait_for_service "frontend" 60 wait_for_service "docs" 60 if [[ " ${compose_cmd[*]} " == *" proxy "* ]]; 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 || warn "Контейнеры не запущены" echo "" if has_cmd nginx && [[ "$(env_get USE_NGINX_SSL false)" == "true" ]]; 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 } 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 write_all_nginx_configs "selfsigned" ok "Самоподписанные сертификаты пересозданы" ;; none) fail "SSL отключён (SSL_TYPE=none). Выберите --ssl selfsigned или letsencrypt." ;; esac } action_restart() { load_env write_compose_override local compose_cmd=(compose --env-file .env) if [[ -n "$(env_get DOMAIN_API "")" && "$(env_get INSTALL_MODE local)" != "local" ]]; then compose_cmd+=(--profile proxy) fi docker_cmd "${compose_cmd[@]}" up -d --build 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 / Let's Encrypt)" echo " 4) Перезапуск / пересборка контейнеров" echo " 5) Статус сервисов" echo " 6) Удалить конфиги Nginx IdP" echo " 0) Выход" echo "" local choice read -r -p "Выберите пункт [0-6]: " 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 "Готово" ;; 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 ;; status) show_status ;; remove-nginx) remove_nginx_configs ;; "") if [[ -f .env ]] && [[ "$NON_INTERACTIVE" -ne 1 ]]; then show_menu else action_full_install fi ;; *) fail "Неизвестное действие: $ACTION" ;; esac } main "$@"