Files
IdP/install.sh
2026-06-24 14:37:15 +03:00

1253 lines
37 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.0.0"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR"
NGINX_PREFIX="lendry-id"
NGINX_AVAILABLE="/etc/nginx/sites-available"
NGINX_ENABLED="/etc/nginx/sites-enabled"
CERTBOT_WEBROOT="/var/www/certbot"
COMPOSE_OVERRIDE="${ROOT_DIR}/docker-compose.override.yml"
STATE_FILE="${ROOT_DIR}/.idp-install.json"
NON_INTERACTIVE=0
FORCE_ENV="${FORCE_ENV:-0}"
ACTION=""
INSTALL_LOCAL=0
CLI_DOMAIN_API=""
CLI_DOMAIN_FRONTEND=""
CLI_DOMAIN_DOCS=""
CLI_DOMAIN_WS=""
CLI_DOMAIN_MINIO=""
CLI_DOMAIN_MINIO_CONSOLE=""
CLI_EMAIL=""
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 --status Статус сервисов
./install.sh --remove-nginx Удалить конфиги Nginx IdP
${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
${BOLD}Прочие опции:${NC}
-y, --yes Без интерактивных вопросов
--local Локальная установка (порты, без Nginx)
-h, --help Справка
${BOLD}Переменные окружения:${NC}
FORCE_ENV=1 Пересоздать .env (секреты будут новые)
${BOLD}Примеры:${NC}
./install.sh
./install.sh --install --yes \\
--api-domain api.id.example.ru \\
--frontend-domain id.example.ru \\
--docs-domain docs.example.ru \\
--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 ;;
--status) ACTION="status"; shift ;;
--remove-nginx) ACTION="remove-nginx"; shift ;;
--local) INSTALL_LOCAL=1; shift ;;
--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() {
[[ "$(uname -s)" == "Linux" ]] || warn "Скрипт рассчитан на Linux. На других ОС установите Docker вручную."
[[ -f docker-compose.yml ]] || fail "Запустите скрипт из корня репозитория (рядом с docker-compose.yml)"
}
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
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_certbot() {
if has_cmd nginx && has_cmd certbot; then
ok "Nginx и Certbot уже установлены"
return
fi
log "Установка Nginx и Certbot..."
case "$PKG_MANAGER" in
apt)
run_root apt-get install -y -qq nginx certbot python3-certbot-nginx
;;
dnf)
run_root dnf install -y nginx certbot
;;
*)
fail "Автоустановка Nginx/Certbot для $OS_ID не поддерживается"
;;
esac
run_root mkdir -p "$CERTBOT_WEBROOT"
run_root chown -R www-data:www-data "$CERTBOT_WEBROOT" 2>/dev/null \
|| run_root chown -R nginx:nginx "$CERTBOT_WEBROOT" 2>/dev/null \
|| true
run_root systemctl enable nginx >/dev/null 2>&1 || true
ok "Nginx и Certbot установлены"
}
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 use_ssl="${1:-false}"
if [[ "$INSTALL_LOCAL" -eq 1 || "$use_ssl" != "true" ]]; 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"
INSTALL_MODE="local"
return
fi
fi
local scheme="https"
[[ "$use_ssl" == "true" ]] || scheme="http"
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
USE_NGINX_SSL=$([[ "$use_ssl" == "true" ]] && echo true || echo false)
if [[ "$INSTALL_LOCAL" -eq 1 ]]; then
INSTALL_MODE="local"
elif [[ -n "${DOMAIN_API:-}" ]]; then
INSTALL_MODE="production"
else
INSTALL_MODE="local"
fi
}
configure_domains_interactive() {
local existing_api existing_frontend existing_docs existing_ws existing_email
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 false
return
fi
echo ""
echo -e "${BOLD}Настройка доменов${NC}"
echo "Укажите отдельный домен для каждого сервиса (A-запись → IP этого сервера)."
echo "Enter — оставить текущее значение. Для локальной установки: ./install.sh --local"
echo ""
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
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)"
local use_ssl="true"
if [[ "$NON_INTERACTIVE" -ne 1 ]]; then
local ssl_answer
read -r -p "Настроить Nginx + Let's Encrypt SSL? [Y/n]: " ssl_answer
ssl_answer="${ssl_answer:-Y}"
[[ "$ssl_answer" =~ ^[Yy]$ ]] || use_ssl="false"
fi
if [[ "$use_ssl" == "true" ]]; then
prompt CERTBOT_EMAIL "Email для Let's Encrypt" "${default_email:-admin@${DOMAIN_FRONTEND}}"
[[ -n "$CERTBOT_EMAIL" ]] || fail "Email обязателен для SSL"
fi
derive_public_urls "$use_ssl"
}
write_env_secrets_if_missing() {
[[ -f .env && "$FORCE_ENV" != "1" ]] && return
local pg_pass rmq_pass minio_pass jwt_access jwt_refresh data_key
pg_pass="$(rand_secret 32)"
rmq_pass="$(rand_secret 32)"
minio_pass="$(rand_secret 24)"
jwt_access="$(rand_secret 64)"
jwt_refresh="$(rand_secret 64)"
data_key="$(rand_secret 48)"
if [[ ! -f .env ]]; then
cp .env.example .env 2>/dev/null || touch .env
fi
if [[ "$FORCE_ENV" == "1" ]] || [[ "$(env_get POSTGRES_PASSWORD change-me-postgres)" == "change-me-postgres" ]]; then
env_set POSTGRES_PASSWORD "$pg_pass"
env_set RABBITMQ_DEFAULT_PASS "$rmq_pass"
env_set MINIO_ROOT_PASSWORD "$minio_pass"
env_set JWT_ACCESS_SECRET "$jwt_access"
env_set JWT_REFRESH_SECRET "$jwt_refresh"
env_set DATA_ENCRYPTION_KEY "$data_key"
ok "Секреты сгенерированы"
fi
}
persist_domain_env() {
write_env_secrets_if_missing
env_set INSTALL_MODE "${INSTALL_MODE:-local}"
env_set DOMAIN_API "${DOMAIN_API:-}"
env_set DOMAIN_FRONTEND "${DOMAIN_FRONTEND:-}"
env_set DOMAIN_DOCS "${DOMAIN_DOCS:-}"
env_set DOMAIN_WS "${DOMAIN_WS:-}"
env_set DOMAIN_MINIO "${DOMAIN_MINIO:-}"
env_set DOMAIN_MINIO_CONSOLE "${DOMAIN_MINIO_CONSOLE:-}"
env_set PUBLIC_API_URL "${PUBLIC_API_URL}"
env_set PUBLIC_FRONTEND_URL "${PUBLIC_FRONTEND_URL}"
env_set PUBLIC_DOCS_URL "${PUBLIC_DOCS_URL}"
env_set PUBLIC_WS_URL "${PUBLIC_WS_URL}"
env_set USE_NGINX_SSL "${USE_NGINX_SSL:-false}"
env_set CERTBOT_EMAIL "${CERTBOT_EMAIL:-}"
env_set MINIO_PUBLIC_ENDPOINT "${MINIO_PUBLIC_ENDPOINT:-localhost:9000}"
env_set MINIO_USE_SSL "${MINIO_USE_SSL:-false}"
ok "Файл .env обновлён"
}
write_compose_override() {
load_env
if [[ -n "$(env_get DOMAIN_API "")" && "$(env_get INSTALL_MODE local)" != "local" ]]; then
log "Привязка контейнеров к 127.0.0.1 (доступ через Nginx)..."
cat >"$COMPOSE_OVERRIDE" <<'EOF'
services:
api-gateway:
ports:
- "127.0.0.1:3000:3000"
frontend:
ports:
- "127.0.0.1:3002:3000"
docs:
ports:
- "127.0.0.1:3003:3000"
media-ws:
ports:
- "127.0.0.1:8085:8085"
minio:
ports:
- "127.0.0.1:9000:9000"
- "127.0.0.1:9001:9001"
EOF
else
[[ -f "$COMPOSE_OVERRIDE" ]] && rm -f "$COMPOSE_OVERRIDE"
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_ws_location=" location /ws {
proxy_pass http://127.0.0.1:8085/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;
}"
write_nginx_site_api() {
local domain="$1"
local ssl="${2:-false}"
local ws_on_api="${3:-true}"
local conf="${NGINX_AVAILABLE}/${NGINX_PREFIX}-api.conf"
local ws_block=""
if [[ "$ws_on_api" == "true" ]]; then
ws_block="$nginx_ws_location"
fi
if [[ "$ssl" == "true" ]]; then
run_root tee "$conf" >/dev/null <<EOF
server {
listen 80;
listen [::]:80;
server_name ${domain};
location /.well-known/acme-challenge/ {
root ${CERTBOT_WEBROOT};
}
location / {
return 301 https://\$host\$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name ${domain};
ssl_certificate /etc/letsencrypt/live/${domain}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/${domain}/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
client_max_body_size 100m;
${ws_block}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
${nginx_proxy_headers}
}
}
EOF
else
run_root tee "$conf" >/dev/null <<EOF
server {
listen 80;
listen [::]:80;
server_name ${domain};
client_max_body_size 100m;
${ws_block}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
${nginx_proxy_headers}
}
}
EOF
fi
run_root ln -sf "$conf" "${NGINX_ENABLED}/${NGINX_PREFIX}-api.conf"
}
write_nginx_site_frontend() {
local domain="$1"
local ssl="${2:-false}"
local conf="${NGINX_AVAILABLE}/${NGINX_PREFIX}-frontend.conf"
if [[ "$ssl" == "true" ]]; then
run_root tee "$conf" >/dev/null <<EOF
server {
listen 80;
listen [::]:80;
server_name ${domain};
location /.well-known/acme-challenge/ {
root ${CERTBOT_WEBROOT};
}
location / {
return 301 https://\$host\$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name ${domain};
ssl_certificate /etc/letsencrypt/live/${domain}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/${domain}/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
client_max_body_size 50m;
location / {
proxy_pass http://127.0.0.1:3002;
proxy_http_version 1.1;
${nginx_proxy_headers}
}
}
EOF
else
run_root tee "$conf" >/dev/null <<EOF
server {
listen 80;
listen [::]:80;
server_name ${domain};
location / {
proxy_pass http://127.0.0.1:3002;
proxy_http_version 1.1;
${nginx_proxy_headers}
}
}
EOF
fi
run_root ln -sf "$conf" "${NGINX_ENABLED}/${NGINX_PREFIX}-frontend.conf"
}
write_nginx_site_docs() {
local domain="$1"
local ssl="${2:-false}"
local conf="${NGINX_AVAILABLE}/${NGINX_PREFIX}-docs.conf"
if [[ "$ssl" == "true" ]]; then
run_root tee "$conf" >/dev/null <<EOF
server {
listen 80;
listen [::]:80;
server_name ${domain};
location /.well-known/acme-challenge/ {
root ${CERTBOT_WEBROOT};
}
location / {
return 301 https://\$host\$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name ${domain};
ssl_certificate /etc/letsencrypt/live/${domain}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/${domain}/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location = / {
return 302 /docs;
}
location / {
proxy_pass http://127.0.0.1:3003;
proxy_http_version 1.1;
${nginx_proxy_headers}
}
}
EOF
else
run_root tee "$conf" >/dev/null <<EOF
server {
listen 80;
listen [::]:80;
server_name ${domain};
location = / {
return 302 /docs;
}
location / {
proxy_pass http://127.0.0.1:3003;
proxy_http_version 1.1;
${nginx_proxy_headers}
}
}
EOF
fi
run_root ln -sf "$conf" "${NGINX_ENABLED}/${NGINX_PREFIX}-docs.conf"
}
write_nginx_site_ws() {
local domain="$1"
local ssl="${2:-false}"
local conf="${NGINX_AVAILABLE}/${NGINX_PREFIX}-ws.conf"
if [[ "$ssl" == "true" ]]; then
run_root tee "$conf" >/dev/null <<EOF
server {
listen 80;
listen [::]:80;
server_name ${domain};
location /.well-known/acme-challenge/ {
root ${CERTBOT_WEBROOT};
}
location / {
return 301 https://\$host\$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name ${domain};
ssl_certificate /etc/letsencrypt/live/${domain}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/${domain}/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass http://127.0.0.1:8085/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
run_root tee "$conf" >/dev/null <<EOF
server {
listen 80;
listen [::]:80;
server_name ${domain};
location / {
proxy_pass http://127.0.0.1:8085/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
run_root ln -sf "$conf" "${NGINX_ENABLED}/${NGINX_PREFIX}-ws.conf"
}
write_nginx_site_minio() {
local domain="$1"
local upstream_port="$2"
local label="$3"
local ssl="${4:-false}"
local conf="${NGINX_AVAILABLE}/${NGINX_PREFIX}-${label}.conf"
if [[ "$ssl" == "true" ]]; then
run_root tee "$conf" >/dev/null <<EOF
server {
listen 80;
listen [::]:80;
server_name ${domain};
location /.well-known/acme-challenge/ {
root ${CERTBOT_WEBROOT};
}
location / {
return 301 https://\$host\$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name ${domain};
ssl_certificate /etc/letsencrypt/live/${domain}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/${domain}/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
client_max_body_size 1024m;
proxy_buffering off;
location / {
proxy_pass http://127.0.0.1:${upstream_port};
proxy_http_version 1.1;
${nginx_proxy_headers}
}
}
EOF
else
run_root tee "$conf" >/dev/null <<EOF
server {
listen 80;
listen [::]:80;
server_name ${domain};
client_max_body_size 1024m;
proxy_buffering off;
location / {
proxy_pass http://127.0.0.1:${upstream_port};
proxy_http_version 1.1;
${nginx_proxy_headers}
}
}
EOF
fi
run_root ln -sf "$conf" "${NGINX_ENABLED}/${NGINX_PREFIX}-${label}.conf"
}
remove_nginx_configs() {
log "Удаление конфигов Nginx IdP..."
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
ok "Конфиги Nginx IdP удалены"
}
write_nginx_http_only() {
load_env
local ssl="false"
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" "$ws_on_api"
write_nginx_site_frontend "$DOMAIN_FRONTEND" "$ssl"
write_nginx_site_docs "$DOMAIN_DOCS" "$ssl"
if [[ "$ws_on_api" == "false" ]]; then
write_nginx_site_ws "$DOMAIN_WS" "$ssl"
else
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" 9000 "minio" "$ssl"
[[ -n "${DOMAIN_MINIO_CONSOLE:-}" ]] && write_nginx_site_minio "$DOMAIN_MINIO_CONSOLE" 9001 "minio-console" "$ssl"
run_root nginx -t
run_root systemctl reload nginx
}
write_nginx_ssl_configs() {
load_env
local ssl="true"
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" "$ws_on_api"
write_nginx_site_frontend "$DOMAIN_FRONTEND" "$ssl"
write_nginx_site_docs "$DOMAIN_DOCS" "$ssl"
if [[ "$ws_on_api" == "false" ]]; then
write_nginx_site_ws "$DOMAIN_WS" "$ssl"
fi
[[ -n "${DOMAIN_MINIO:-}" ]] && write_nginx_site_minio "$DOMAIN_MINIO" 9000 "minio" "$ssl"
[[ -n "${DOMAIN_MINIO_CONSOLE:-}" ]] && write_nginx_site_minio "$DOMAIN_MINIO_CONSOLE" 9001 "minio-console" "$ssl"
run_root nginx -t
run_root systemctl reload nginx
}
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 USE_NGINX_SSL false)" == "true" ]] || 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
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
run_root openssl dhparam -out /etc/letsencrypt/ssl-dhparams.pem 2048 2>/dev/null || true
fi
write_nginx_ssl_configs
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
install_nginx_certbot
setup_firewall
if [[ "$(env_get USE_NGINX_SSL false)" == "true" ]]; then
run_root mkdir -p "$CERTBOT_WEBROOT"
obtain_all_certificates
else
write_nginx_http_only
ok "Nginx настроен (HTTP без SSL)"
fi
}
setup_nginx_stack() {
setup_nginx_proxy
}
wait_for_service() {
local name="$1"
local max_attempts="${2:-60}"
local attempt=0
log "Ожидание сервиса ${name}..."
while [[ $attempt -lt $max_attempts ]]; do
if docker_cmd compose ps --status running "$name" 2>/dev/null | grep -q "$name"; then
local cid health
cid="$(docker_cmd compose ps -q "$name" 2>/dev/null | head -n1)"
health="$(docker_cmd inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$cid" 2>/dev/null || echo "none")"
if [[ "$health" == "healthy" || "$health" == "none" ]]; then
ok "Сервис ${name} готов"
return 0
fi
fi
attempt=$((attempt + 1))
sleep 5
done
warn "Сервис ${name} не стал healthy — проверьте: docker compose logs $name"
}
deploy_stack() {
export DOCKER_BUILDKIT=1
export COMPOSE_DOCKER_CLI_BUILD=1
write_compose_override
load_env
log "Сборка и запуск контейнеров (1020 минут при первом запуске)..."
docker_cmd compose --env-file .env up -d --build
wait_for_service "api-gateway" 90
wait_for_service "frontend" 60
wait_for_service "docs" 60
}
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_summary() {
load_env
echo ""
echo -e "${GREEN}══════════════════════════════════════════════════════════════${NC}"
echo -e "${GREEN} Lendry ID — развёртывание завершено${NC}"
echo -e "${GREEN}══════════════════════════════════════════════════════════════${NC}"
echo ""
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}"
if [[ "$(env_get USE_NGINX_SSL false)" != "true" ]]; then
echo -e " ${BOLD}MinIO Console:${NC} http://localhost:9001"
echo -e " ${BOLD}RabbitMQ UI:${NC} http://localhost:15672"
fi
echo ""
echo -e " ${BOLD}Управление:${NC} ./install.sh"
echo -e " ${BOLD}Смена доменов:${NC} ./install.sh --configure-domains"
echo -e " ${BOLD}Логи:${NC} docker compose logs -f api-gateway sso-core"
echo ""
echo " Первый зарегистрированный пользователь станет супер-администратором."
echo " Секреты в .env — не коммитьте в git."
echo ""
if [[ "${DOCKER_SUGGESTED_GROUP:-}" == "1" ]]; then
warn "Docker без sudo: выполните newgrp docker или перелогиньтесь"
fi
}
show_status() {
load_env
echo ""
echo -e "${BOLD}Статус Lendry ID${NC}"
echo ""
echo " INSTALL_MODE: $(env_get INSTALL_MODE local)"
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 has_cmd nginx && [[ "$(env_get USE_NGINX_SSL false)" == "true" ]]; then
run_root nginx -t 2>&1 | sed 's/^/ /'
fi
}
action_full_install() {
configure_domains_interactive
persist_domain_env
setup_nginx_proxy
deploy_stack
save_install_state
print_summary
}
action_configure_domains() {
if [[ ! -f .env ]]; then
warn ".env не найден — выполняем полную установку"
action_full_install
return
fi
load_env
configure_domains_interactive
persist_domain_env
if [[ "$INSTALL_LOCAL" -eq 1 ]]; then
remove_nginx_configs
fi
setup_nginx_proxy
write_compose_override
log "Пересборка frontend/docs с новыми URL..."
deploy_stack
save_install_state
print_summary
}
action_renew_ssl() {
load_env
[[ "$(env_get USE_NGINX_SSL false)" == "true" ]] || fail "SSL не используется (USE_NGINX_SSL=false)"
install_nginx_certbot
run_root certbot renew --quiet
write_nginx_ssl_configs
ok "SSL-сертификаты обновлены"
}
action_restart() {
load_env
write_compose_override
docker_cmd compose --env-file .env up -d --build
ok "Контейнеры перезапущены"
show_status
}
show_menu() {
while true; do
echo ""
echo -e "${BOLD}══════════════════════════════════════════════════════════════${NC}"
echo -e "${BOLD} Lendry ID v${SCRIPT_VERSION} — установка и управление${NC}"
echo -e "${BOLD}══════════════════════════════════════════════════════════════${NC}"
[[ -f .env ]] && load_env && echo -e " Режим: ${INSTALL_MODE:-local} | API: ${DOMAIN_API:-localhost:3000}"
echo ""
echo " 1) Полная установка / переустановка стека"
echo " 2) Настроить / изменить домены и Nginx + SSL"
echo " 3) Обновить SSL-сертификаты (Let's Encrypt)"
echo " 4) Перезапуск / пересборка контейнеров"
echo " 5) Статус сервисов"
echo " 6) Удалить конфиги Nginx IdP"
echo " 0) Выход"
echo ""
local choice
read -r -p "Выберите пункт [0-6]: " choice
case "$choice" in
1) NON_INTERACTIVE=0; action_full_install ;;
2) NON_INTERACTIVE=0; action_configure_domains ;;
3) action_renew_ssl ;;
4) action_restart ;;
5) show_status ;;
6) remove_nginx_configs; ok "Готово" ;;
0) exit 0 ;;
*) warn "Неверный пункт" ;;
esac
done
}
main() {
parse_args "$@"
preflight_checks
detect_os
install_base_packages
install_docker
check_docker_compose
case "${ACTION:-}" in
install) action_full_install ;;
configure-domains) action_configure_domains ;;
renew-ssl) action_renew_ssl ;;
restart) action_restart ;;
status) show_status ;;
remove-nginx) remove_nginx_configs ;;
"")
if [[ -f .env ]] && [[ "$NON_INTERACTIVE" -ne 1 ]]; then
show_menu
else
action_full_install
fi
;;
*) fail "Неизвестное действие: $ACTION" ;;
esac
}
main "$@"