Files
IdP/install.sh
2026-06-29 17:00:12 +03:00

2793 lines
95 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# Lendry ID — установка, Nginx, SSL и управление одной командой
#
# Использование:
# chmod +x install.sh && ./install.sh
# ./install.sh --install --api-domain api.example.com --frontend-domain id.example.com \
# --docs-domain docs.example.com --email admin@example.com
# ./install.sh --configure-domains # изменить домены и пересобрать
# ./install.sh --local # локально без Nginx
set -euo pipefail
SCRIPT_VERSION="2.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 <<EOF
${BOLD}Lendry ID v${SCRIPT_VERSION}${NC} — установка и управление
./install.sh Интерактивное меню
./install.sh --install Полная установка
./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 --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
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
EOF
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
-y | --yes) NON_INTERACTIVE=1; shift ;;
--install) ACTION="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 ;;
--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}"; 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" ]]
}
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" == "<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
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' </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
}
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
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" <<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 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)
[[ "$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
}
# Регистрируемый домен (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_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
(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
}
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 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;"
# Встроенный DNS Docker. В docker-режиме nginx обязан перерезолвлять имена
# бэкенд-контейнеров на каждый запрос — иначе после пересборки контейнера
# (новый IP) остаётся закешированный старый адрес и все ответы становятся 502.
NGINX_DOCKER_RESOLVER=" resolver 127.0.0.11 ipv6=off valid=10s;"
# Имя nginx-переменной из ключа апстрима (только [A-Za-z0-9_]).
nginx_upstream_var() {
printf 'idp_up_%s' "$(printf '%s' "$1" | tr -c 'A-Za-z0-9' '_')"
}
# proxy_pass к КОРНЮ апстрима ($request_uri сохраняет путь и query).
# docker: через resolver+переменную (переживает смену IP контейнера).
# host: статический proxy_pass на 127.0.0.1 (DNS-кеша нет, IP не меняется).
render_proxy_root() {
local key="$1" up var
up="$(nginx_upstream "$key")"
if [[ "$NGINX_MODE" == "docker" ]]; then
var="$(nginx_upstream_var "$key")"
printf '%s\n set $%s %s;\n proxy_pass $%s$request_uri;' \
"$NGINX_DOCKER_RESOLVER" "$var" "$up" "$var"
else
printf ' proxy_pass %s;' "$up"
fi
}
# 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="$(nginx_upstream_var "$key")"
printf '%s\n set $%s %s;\n rewrite ^.*$ %s break;\n proxy_pass $%s;' \
"$NGINX_DOCKER_RESOLVER" "$var" "$up" "$target" "$var"
else
printf ' proxy_pass %s%s;' "$up" "$target"
fi
}
# proxy_pass для /idp-api/ — срезает префикс /idp-api/ и проксирует на корень.
render_proxy_idp_api() {
local key="api" up var
up="$(nginx_upstream "$key")"
if [[ "$NGINX_MODE" == "docker" ]]; then
var="$(nginx_upstream_var "$key")"
printf '%s\n set $%s %s;\n rewrite ^/idp-api/(.*)$ /$1 break;\n proxy_pass $%s;' \
"$NGINX_DOCKER_RESOLVER" "$var" "$up" "$var"
else
printf ' proxy_pass %s/;' "$up"
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).
# Так как фронтенд и API на разных доменах (split-domain), браузер шлёт
# cross-origin запросы и preflight (OPTIONS). Nginx сам отвечает на preflight
# и проставляет CORS-заголовки, отражая Origin (нужно для credentials: cookie/JWT).
# Заголовки CORS от upstream скрываем, чтобы не было дублей Access-Control-Allow-Origin.
build_nginx_api_location_block() {
local proxy_api
proxy_api="$(render_proxy_root api)"
cat <<EOF
location / {
set \$cors_origin "";
if (\$http_origin) { set \$cors_origin \$http_origin; }
if (\$request_method = OPTIONS) {
add_header Access-Control-Allow-Origin \$cors_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, Accept, Origin, X-CSRF-Token" always;
add_header Access-Control-Max-Age 86400 always;
add_header Content-Length 0;
return 204;
}
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Credentials;
add_header Access-Control-Allow-Origin \$cors_origin always;
add_header Access-Control-Allow-Credentials true always;
${proxy_api}
proxy_http_version 1.1;
${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_location
conf="$(nginx_conf_target api)"
ws_block="$(build_ws_block "$ws_on_api")"
http_preamble="$(nginx_http_server_preamble "$domain" "$ssl_type")"
api_location="$(build_nginx_api_location_block)"
if [[ "$ssl_type" == "none" ]]; then
nginx_write_conf "$conf" <<EOF
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
${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_rewrite ws /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
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;
}
location /idp-api/ {
${proxy_idp}
proxy_http_version 1.1;
${nginx_proxy_headers}
}
location /oauth/ {
${proxy_oauth}
proxy_http_version 1.1;
${nginx_proxy_headers}
}
location /.well-known/ {
${proxy_wk}
proxy_http_version 1.1;
${nginx_proxy_headers}
}
location /fedcm/ {
${proxy_fedcm}
proxy_http_version 1.1;
${nginx_proxy_headers}
}
EOF
}
write_nginx_site_frontend() {
local domain="$1"
local ssl_type="${2:-none}"
local conf http_preamble ssl_block idp_api_block proxy_root
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)"
if [[ "$ssl_type" == "none" ]]; then
nginx_write_conf "$conf" <<EOF
server {
listen 80;
listen [::]:80;
server_name ${domain};
${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;
${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
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)"
if [[ "$ssl_type" == "none" ]]; then
nginx_write_conf "$conf" <<EOF
server {
listen 80;
listen [::]:80;
server_name ${domain};
client_max_body_size 50m;
${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;
${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" ]]; 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"
# 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"
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"
# Сертификат для 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
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 "Сборка и запуск контейнеров (1020 минут при первом запуске)..."
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" <<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 (общий для всех доменов)"
echo " Браузер покажет предупреждение — примите сертификат для ${DOMAIN_FRONTEND}."
echo " API вызывается через ${DOMAIN_FRONTEND}/idp-api — отдельно открывать ${DOMAIN_API} не нужно."
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 ""
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}"
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)"
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/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-сертификаты (обновить / включить 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 "$@"