2031 lines
66 KiB
Bash
2031 lines
66 KiB
Bash
#!/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
|
||
|
||
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" ]]; 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 ""
|
||
|
||
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 [[ "$ssl_type" == "selfsigned" ]]; then
|
||
if [[ "$NGINX_MODE" == "docker" ]]; then
|
||
echo "/etc/nginx/certs/shared-intranet"
|
||
else
|
||
echo "${LOCAL_CERTS_DIR}/shared-intranet"
|
||
fi
|
||
elif [[ "$NGINX_MODE" == "docker" ]]; then
|
||
echo "/etc/nginx/certs/${domain}"
|
||
else
|
||
echo "${LOCAL_CERTS_DIR}/${domain}"
|
||
fi
|
||
}
|
||
|
||
intranet_wildcard_base_domain() {
|
||
local domain="${1:-}"
|
||
[[ -z "$domain" ]] && return 0
|
||
if [[ "$domain" == *.*.* ]]; then
|
||
echo "${domain#*.}"
|
||
else
|
||
echo "$domain"
|
||
fi
|
||
}
|
||
|
||
collect_self_signed_sans() {
|
||
local -a sans=()
|
||
local base domain
|
||
|
||
load_env
|
||
for domain in "$DOMAIN_API" "$DOMAIN_FRONTEND" "$DOMAIN_DOCS" "${DOMAIN_WS:-}" "${DOMAIN_MINIO:-}" "${DOMAIN_MINIO_CONSOLE:-}"; do
|
||
[[ -z "$domain" ]] && continue
|
||
sans+=("DNS:${domain}")
|
||
done
|
||
|
||
base="$(intranet_wildcard_base_domain "${DOMAIN_FRONTEND:-$DOMAIN_API}")"
|
||
if [[ -n "$base" ]]; then
|
||
sans+=("DNS:*.${base}")
|
||
sans+=("DNS:${base}")
|
||
fi
|
||
|
||
(IFS=,; echo "${sans[*]}")
|
||
}
|
||
|
||
generate_shared_self_signed_cert() {
|
||
local cert_dir="${LOCAL_CERTS_DIR}/shared-intranet"
|
||
local base sans cn
|
||
|
||
mkdir -p "$cert_dir"
|
||
base="$(intranet_wildcard_base_domain "${DOMAIN_FRONTEND:-$DOMAIN_API}")"
|
||
[[ -z "$base" ]] && base="${DOMAIN_FRONTEND:-$DOMAIN_API}"
|
||
cn="*.${base}"
|
||
sans="$(collect_self_signed_sans)"
|
||
|
||
if [[ -f "${cert_dir}/fullchain.pem" && -f "${cert_dir}/privkey.pem" ]]; then
|
||
return 0
|
||
fi
|
||
|
||
log "Общий самоподписанный сертификат: ${cn} (${sans})"
|
||
if has_cmd openssl; then
|
||
openssl req -x509 -nodes -days 825 -newkey rsa:2048 \
|
||
-keyout "${cert_dir}/privkey.pem" \
|
||
-out "${cert_dir}/fullchain.pem" \
|
||
-subj "/CN=${cn}/O=Lendry ID Intranet/C=RU" \
|
||
-addext "subjectAltName=${sans}" 2>/dev/null || \
|
||
openssl req -x509 -nodes -days 825 -newkey rsa:2048 \
|
||
-keyout "${cert_dir}/privkey.pem" \
|
||
-out "${cert_dir}/fullchain.pem" \
|
||
-subj "/CN=${cn}/O=Lendry ID Intranet/C=RU"
|
||
chmod 600 "${cert_dir}/privkey.pem" 2>/dev/null || true
|
||
else
|
||
fail "openssl не найден — нужен для самоподписанных сертификатов"
|
||
fi
|
||
}
|
||
|
||
generate_all_self_signed_certs() {
|
||
local force="${1:-}"
|
||
load_env
|
||
if [[ "$force" == "--force" ]]; then
|
||
rm -f "${LOCAL_CERTS_DIR}/shared-intranet/fullchain.pem" "${LOCAL_CERTS_DIR}/shared-intranet/privkey.pem"
|
||
fi
|
||
generate_shared_self_signed_cert
|
||
ok "Самоподписанный сертификат создан: ${LOCAL_CERTS_DIR}/shared-intranet"
|
||
}
|
||
|
||
nginx_conf_target() {
|
||
local label="$1"
|
||
if [[ "$NGINX_MODE" == "docker" ]]; then
|
||
echo "${LOCAL_CONF_DIR}/${NGINX_PREFIX}-${label}.conf"
|
||
else
|
||
echo "${NGINX_AVAILABLE}/${NGINX_PREFIX}-${label}.conf"
|
||
fi
|
||
}
|
||
|
||
nginx_publish_config() {
|
||
local label="$1"
|
||
local conf
|
||
conf="$(nginx_conf_target "$label")"
|
||
if [[ "$NGINX_MODE" == "host" ]]; then
|
||
run_root ln -sf "$conf" "${NGINX_ENABLED}/${NGINX_PREFIX}-${label}.conf"
|
||
fi
|
||
}
|
||
|
||
nginx_proxy_headers=" proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_set_header X-Forwarded-Host \$host;
|
||
proxy_read_timeout 300s;
|
||
proxy_connect_timeout 75s;"
|
||
|
||
nginx_write_conf() {
|
||
local conf="$1"
|
||
if [[ "$NGINX_MODE" == "docker" ]]; then
|
||
cat >"$conf"
|
||
else
|
||
run_root tee "$conf" >/dev/null
|
||
fi
|
||
}
|
||
|
||
nginx_ssl_directives() {
|
||
local domain="$1"
|
||
local ssl_type="$2"
|
||
local cert_base
|
||
cert_base="$(nginx_cert_base_path "$domain" "$ssl_type")"
|
||
if [[ "$ssl_type" == "letsencrypt" ]]; then
|
||
cat <<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"
|
||
}
|
||
|
||
build_nginx_idp_api_proxy_block() {
|
||
local api_upstream
|
||
api_upstream="$(nginx_upstream api)"
|
||
cat <<EOF
|
||
location /idp-api/ws {
|
||
proxy_pass ${api_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;
|
||
}
|
||
|
||
location /idp-api/ {
|
||
proxy_pass ${api_upstream}/;
|
||
proxy_http_version 1.1;
|
||
${nginx_proxy_headers}
|
||
}
|
||
|
||
EOF
|
||
}
|
||
|
||
write_nginx_site_frontend() {
|
||
local domain="$1"
|
||
local ssl_type="${2:-none}"
|
||
local conf upstream http_preamble ssl_block idp_api_block
|
||
conf="$(nginx_conf_target frontend)"
|
||
upstream="$(nginx_upstream 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_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;
|
||
${idp_api_block}
|
||
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 "Сборка и запуск контейнеров (10–20 минут при первом запуске)..."
|
||
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}/shared-intranet (общий для всех доменов)"
|
||
echo " Браузер покажет предупреждение — примите сертификат для ${DOMAIN_FRONTEND}."
|
||
echo " API вызывается через ${DOMAIN_FRONTEND}/idp-api — отдельно открывать ${DOMAIN_API} не нужно."
|
||
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}/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 ""
|
||
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} — недоступен"
|
||
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)"
|
||
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 --force
|
||
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
|
||
|
||
if [[ "$(env_get SSL_TYPE none)" == "selfsigned" && -n "$(env_get DOMAIN_FRONTEND "")" ]]; then
|
||
derive_public_urls "selfsigned"
|
||
env_set PUBLIC_API_URL "${PUBLIC_API_URL}"
|
||
env_set PUBLIC_WS_URL "${PUBLIC_WS_URL}"
|
||
log "Шаг 5/5: пересборка frontend (same-origin API для HTTPS)..."
|
||
build_compose_stack_cmd
|
||
docker_cmd "${COMPOSE_STACK_CMD[@]}" up -d --build frontend
|
||
fi
|
||
|
||
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 "$@"
|