Files
IdP/install.sh
2026-06-24 16:53:06 +03:00

1939 lines
63 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.2"
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"
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}"
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_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
./install.sh --restart Перезапуск контейнеров
./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 | letsencrypt (по умолчанию в --intranet: none)
--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
${BOLD}Примеры:${NC}
./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 ;;
--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 ;;
--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"
}
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
}
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" ]] && 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
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 MINIO_PUBLIC_ENDPOINT "${MINIO_PUBLIC_ENDPOINT:-localhost:9000}"
env_set MINIO_USE_SSL "${MINIO_USE_SSL:-false}"
ok "Файл .env обновлён"
}
write_compose_override() {
resolve_nginx_mode_and_ports
load_env
local mode="${NGINX_MODE:-$(env_get NGINX_MODE host)}"
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})..."
# Порты edge-сервисов и MinIO (127.0.0.1) заданы в docker-compose.yml.
# Override только добавляет Docker Nginx при NGINX_MODE=docker.
if [[ "$mode" == "docker" ]]; then
local http_port https_port
http_port="$(env_get NGINX_HTTP_PORT 80)"
https_port="$(env_get NGINX_HTTPS_PORT 443)"
cat >"$COMPOSE_OVERRIDE" <<EOF
services:
nginx:
ports:
- "${http_port}:80"
- "${https_port}:443"
EOF
else
# host Nginx — override не нужен; удаляем старый (мог содержать ports: !reset [])
[[ -f "$COMPOSE_OVERRIDE" ]] && rm -f "$COMPOSE_OVERRIDE"
fi
}
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 [[ "$NGINX_MODE" == "docker" ]]; then
echo "/etc/nginx/certs/${domain}"
else
echo "${LOCAL_CERTS_DIR}/${domain}"
fi
}
generate_self_signed_cert() {
local domain="$1"
local cert_dir="${LOCAL_CERTS_DIR}/${domain}"
mkdir -p "$cert_dir"
if [[ -f "${cert_dir}/fullchain.pem" && -f "${cert_dir}/privkey.pem" ]]; then
return 0
fi
log "Самоподписанный сертификат: ${domain}"
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=${domain}/O=Lendry ID Intranet/C=RU" \
-addext "subjectAltName=DNS:${domain}" 2>/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 <<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" ]]; 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 upstream
upstream="$(nginx_upstream ws)"
cat <<EOF
location /ws {
proxy_pass ${upstream}/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
}
write_nginx_site_api() {
local domain="$1"
local ssl_type="${2:-none}"
local ws_on_api="${3:-true}"
local conf upstream ws_block http_preamble ssl_block
conf="$(nginx_conf_target api)"
upstream="$(nginx_upstream api)"
ws_block="$(build_ws_block "$ws_on_api")"
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 100m;
${ws_block}
location / {
proxy_pass ${upstream};
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 100m;
${ws_block}
location / {
proxy_pass ${upstream};
proxy_http_version 1.1;
${nginx_proxy_headers}
}
}
EOF
fi
nginx_publish_config "api"
}
write_nginx_site_frontend() {
local domain="$1"
local ssl_type="${2:-none}"
local conf upstream http_preamble ssl_block
conf="$(nginx_conf_target frontend)"
upstream="$(nginx_upstream frontend)"
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_pass ${upstream};
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;
location / {
proxy_pass ${upstream};
proxy_http_version 1.1;
${nginx_proxy_headers}
}
}
EOF
fi
nginx_publish_config "frontend"
}
write_nginx_site_docs() {
local domain="$1"
local ssl_type="${2:-none}"
local conf upstream http_preamble ssl_block
conf="$(nginx_conf_target docs)"
upstream="$(nginx_upstream 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_pass ${upstream};
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_pass ${upstream};
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 upstream http_preamble ssl_block
conf="$(nginx_conf_target ws)"
upstream="$(nginx_upstream 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_pass ${upstream}/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_pass ${upstream}/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 upstream http_preamble ssl_block
conf="$(nginx_conf_target "$label")"
upstream="$(nginx_upstream "$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_pass ${upstream};
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_pass ${upstream};
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"
if [[ "$ws_on_api" == "false" ]]; then
write_nginx_site_ws "$DOMAIN_WS" "$ssl_type"
else
rm -f "${LOCAL_CONF_DIR}/${NGINX_PREFIX}-ws.conf" 2>/dev/null || true
run_root rm -f "${NGINX_ENABLED}/${NGINX_PREFIX}-ws.conf" "${NGINX_AVAILABLE}/${NGINX_PREFIX}-ws.conf" 2>/dev/null || true
fi
[[ -n "${DOMAIN_MINIO:-}" ]] && write_nginx_site_minio "$DOMAIN_MINIO" minio minio "$ssl_type"
[[ -n "${DOMAIN_MINIO_CONSOLE:-}" ]] && write_nginx_site_minio "$DOMAIN_MINIO_CONSOLE" minio-console minio-console "$ssl_type"
if [[ "$NGINX_MODE" == "host" ]]; then
if run_root nginx -t; then
run_root systemctl reload nginx || warn "Не удалось перезагрузить Nginx (systemctl reload nginx)"
else
warn "Конфигурация Nginx содержит ошибки — проверьте сертификаты в ${LOCAL_CERTS_DIR}"
fi
else
ok "Конфиги Nginx: ${LOCAL_CONF_DIR}"
fi
}
write_nginx_http_only() {
write_all_nginx_configs "none"
}
write_nginx_ssl_configs() {
load_env
write_all_nginx_configs "$(env_get SSL_TYPE selfsigned)"
}
obtain_certificate() {
local domain="$1"
local email="$2"
[[ -z "$domain" ]] && return 0
log "Получение SSL для ${domain}..."
if ! run_root certbot certonly --webroot -w "$CERTBOT_WEBROOT" \
--email "$email" --agree-tos --no-eff-email \
-d "$domain" --non-interactive; then
fail "Не удалось получить SSL для ${domain}. Проверьте DNS A-запись → IP сервера и порты 80/443."
fi
ok "Сертификат для ${domain} готов"
}
obtain_all_certificates() {
load_env
[[ "$(env_get SSL_TYPE none)" == "letsencrypt" ]] || return 0
local email="${CERTBOT_EMAIL:-}"
[[ -n "$email" ]] || fail "CERTBOT_EMAIL не задан"
write_nginx_http_only
obtain_certificate "$DOMAIN_API" "$email"
obtain_certificate "$DOMAIN_FRONTEND" "$email"
obtain_certificate "$DOMAIN_DOCS" "$email"
if [[ -n "${DOMAIN_WS:-}" && "${DOMAIN_WS}" != "${DOMAIN_API}" ]]; then
obtain_certificate "$DOMAIN_WS" "$email"
fi
[[ -n "${DOMAIN_MINIO:-}" ]] && obtain_certificate "$DOMAIN_MINIO" "$email"
[[ -n "${DOMAIN_MINIO_CONSOLE:-}" ]] && obtain_certificate "$DOMAIN_MINIO_CONSOLE" "$email"
if [[ ! -f /etc/letsencrypt/options-ssl-nginx.conf ]]; then
run_root mkdir -p /etc/letsencrypt
if [[ "$OFFLINE" -ne 1 ]]; then
run_root curl -fsSL https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf \
-o /etc/letsencrypt/options-ssl-nginx.conf 2>/dev/null || true
fi
run_root openssl dhparam -out /etc/letsencrypt/ssl-dhparams.pem 2048 2>/dev/null || true
fi
write_all_nginx_configs "letsencrypt"
setup_certbot_renewal
}
setup_certbot_renewal() {
if has_cmd systemctl && systemctl list-unit-files 2>/dev/null | grep -q certbot.timer; then
run_root systemctl enable certbot.timer >/dev/null 2>&1 || true
run_root systemctl start certbot.timer >/dev/null 2>&1 || true
ok "Автообновление SSL (certbot.timer) включено"
else
warn "certbot.timer не найден — настройте cron: certbot renew --quiet && systemctl reload nginx"
fi
}
setup_nginx_proxy() {
load_env
[[ -n "${DOMAIN_API:-}" ]] || return 0
[[ "$INSTALL_LOCAL" -eq 1 ]] && return 0
resolve_nginx_mode_and_ports
write_compose_override
if [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
stop_docker_nginx
fi
install_nginx_certbot
local ssl_type
ssl_type="$(env_get SSL_TYPE none)"
case "$ssl_type" in
none)
write_nginx_http_only
ok "Nginx: HTTP — локальная сеть, интернет не нужен"
;;
selfsigned)
generate_all_self_signed_certs
write_all_nginx_configs "selfsigned"
ok "Nginx: HTTPS самоподписанный — импортируйте CA в доверенные на клиентах или примите предупреждение браузера"
;;
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
prepare_compose_stack
verify_postgres_auth
build_compose_stack_cmd
log "Сборка и запуск контейнеров (1020 минут при первом запуске)..."
docker_cmd "${COMPOSE_STACK_CMD[@]}" up -d --build
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-сервера>"
echo -e " ${BOLD}Локальная сеть (DNS / hosts):${NC}"
echo " Пропишите во внутреннем DNS или C:\\Windows\\System32\\drivers\\etc\\hosts:"
echo " ${server_ip} ${DOMAIN_API} ${DOMAIN_FRONTEND} ${DOMAIN_DOCS}"
echo ""
if [[ "$(env_get SSL_TYPE none)" == "selfsigned" ]]; then
echo " Сертификаты: ${LOCAL_CERTS_DIR}/"
echo " Браузер покажет предупреждение — это нормально для локальных доменов."
echo ""
fi
}
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}/api"
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 ""
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?)"
;;
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} — недоступен (502 Bad Gateway)"
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/api"
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)"
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 ""
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
}
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_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: удаление override (мог блокировать порты)..."
rm -f "$COMPOSE_OVERRIDE"
log "Шаг 3/4: пересоздание api-gateway, frontend, docs, media-ws..."
build_compose_stack_cmd
docker_cmd "${COMPOSE_STACK_CMD[@]}" up -d --force-recreate "${HOST_EDGE_SERVICES[@]}"
ensure_host_upstreams
log "Шаг 4/4: конфиги системного Nginx..."
refresh_host_nginx_configs
ok "Прокси исправлен — проверьте: curl -I http://127.0.0.1:3002"
show_status
}
action_restart() {
load_env
prepare_compose_stack
refresh_host_nginx_configs
build_compose_stack_cmd
docker_cmd "${COMPOSE_STACK_CMD[@]}" up -d --build
recreate_host_port_services
ensure_host_upstreams
ok "Контейнеры перезапущены"
show_status
}
show_menu() {
while true; do
echo ""
echo -e "${BOLD}══════════════════════════════════════════════════════════════${NC}"
echo -e "${BOLD} Lendry ID v${SCRIPT_VERSION} — установка и управление${NC}"
echo -e "${BOLD}══════════════════════════════════════════════════════════════${NC}"
[[ -f .env ]] && load_env && echo -e " Режим: ${INSTALL_MODE:-local} | API: ${DOMAIN_API:-localhost:3000}"
echo ""
echo " 1) Полная установка / переустановка стека"
echo " 2) Настроить / изменить домены и Nginx + SSL"
echo " 3) Обновить SSL-сертификаты (selfsigned / Let's Encrypt)"
echo " 4) Перезапуск / пересборка контейнеров"
echo " 5) Статус сервисов"
echo " 6) Удалить конфиги Nginx IdP"
echo " 7) Сброс PostgreSQL (если ошибка P1000 / пароль БД)"
echo " 0) Выход"
echo ""
local choice
read -r -p "Выберите пункт [0-7]: " 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 ;;
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 ;;
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 "$@"