#!/usr/bin/env bash # VPN Service Bot — интерактивный установщик (Docker + Postgres + Caddy SSL) # Запуск на Ubuntu 22.04 / 24.04: # cd /path/to/project && sudo bash install.sh # # Если видишь ошибку вроде «set: pipefail» / «invalid option name» — # файл сохранён с Windows-переносами (CRLF). Исправь так: # sed -i 's/\r$//' install.sh && sudo bash install.sh set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" 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 "${GREEN}✓${NC} $*"; } warn() { echo -e "${YELLOW}!${NC} $*"; } err() { echo -e "${RED}✗${NC} $*" >&2; } info() { echo -e "${CYAN}→${NC} $*"; } title() { echo echo -e "${BOLD}════════════════════════════════════════${NC}" echo -e "${BOLD} $*${NC}" echo -e "${BOLD}════════════════════════════════════════${NC}" } need_root() { if [[ "${EUID}" -ne 0 ]]; then err "Запусти от root: sudo bash install.sh" exit 1 fi } ask() { # ask "Prompt" "default" → sets REPLY local prompt="$1" local default="${2:-}" if [[ -n "$default" ]]; then read -r -p "$(echo -e "${CYAN}?${NC} ${prompt} [${default}]: ")" REPLY REPLY="${REPLY:-$default}" else read -r -p "$(echo -e "${CYAN}?${NC} ${prompt}: ")" REPLY fi } ask_secret() { local prompt="$1" local default="${2:-}" if [[ -n "$default" ]]; then read -r -s -p "$(echo -e "${CYAN}?${NC} ${prompt} [скрыто / Enter = сгенерировать]: ")" REPLY echo REPLY="${REPLY:-$default}" else read -r -s -p "$(echo -e "${CYAN}?${NC} ${prompt}: ")" REPLY echo fi } ask_yn() { local prompt="$1" local default="${2:-y}" local hint="y/n" [[ "$default" == "y" ]] && hint="Y/n" [[ "$default" == "n" ]] && hint="y/N" while true; do read -r -p "$(echo -e "${CYAN}?${NC} ${prompt} [${hint}]: ")" REPLY REPLY="${REPLY:-$default}" case "${REPLY,,}" in y|yes|д|да) REPLY=y; return 0 ;; n|no|н|нет) REPLY=n; return 0 ;; esac warn "Ответь y или n" done } rand_pass() { if command -v openssl >/dev/null 2>&1; then openssl rand -base64 24 | tr -d '/+=' | head -c 24 else head -c 48 /dev/urandom | base64 | tr -d '/+=' | head -c 24 fi } env_escape() { # Значение для .env: кавычки если есть спецсимволы local v="$1" if [[ -z "$v" ]]; then echo "" return fi if [[ "$v" =~ [[:space:]\#\"\'\\\$\`] ]]; then v="${v//\\/\\\\}" v="${v//\"/\\\"}" echo "\"$v\"" else echo "$v" fi } check_os() { if [[ ! -f /etc/os-release ]]; then warn "Не Linux? Скрипт рассчитан на Ubuntu 22.04/24.04" return fi # shellcheck source=/dev/null . /etc/os-release info "ОС: ${PRETTY_NAME:-unknown}" if [[ "${ID:-}" != "ubuntu" && "${ID:-}" != "debian" ]]; then warn "Рекомендуется Ubuntu/Debian. Продолжаем на свой страх." fi } install_docker() { title "Docker" if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then log "Docker уже установлен: $(docker --version)" log "Compose: $(docker compose version)" return fi ask_yn "Docker не найден. Установить Docker Engine + Compose?" "y" if [[ "$REPLY" != "y" ]]; then err "Без Docker установка невозможна" exit 1 fi info "Установка Docker…" apt-get update -y apt-get install -y ca-certificates curl gnupg install -m 0755 -d /etc/apt/keyrings if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc chmod a+r /etc/apt/keyrings/docker.asc fi local codename # shellcheck source=/dev/null . /etc/os-release codename="${VERSION_CODENAME:-jammy}" if [[ "${ID:-}" == "debian" ]]; then echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian ${codename} stable" \ > /etc/apt/sources.list.d/docker.list else echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu ${codename} stable" \ > /etc/apt/sources.list.d/docker.list fi apt-get update -y apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin systemctl enable --now docker log "Docker установлен" } check_ports() { title "Порты 80 / 443 (нужны для Caddy SSL)" local busy=0 for port in 80 443; do if ss -tlnp 2>/dev/null | grep -qE ":${port}\\b"; then warn "Порт ${port} занят:" ss -tlnp 2>/dev/null | grep -E ":${port}\\b" || true busy=1 else log "Порт ${port} свободен" fi done if [[ "$busy" -eq 1 ]]; then ask_yn "Порты заняты. Остановить конфликтующие сервисы (nginx/apache2/caddy)?" "n" if [[ "$REPLY" == "y" ]]; then systemctl stop nginx 2>/dev/null || true systemctl disable nginx 2>/dev/null || true systemctl stop apache2 2>/dev/null || true systemctl disable apache2 2>/dev/null || true systemctl stop caddy 2>/dev/null || true # чужой docker caddy docker ps --format '{{.Names}}' 2>/dev/null | while read -r name; do if docker port "$name" 2>/dev/null | grep -qE ':80|:443'; then warn "Останавливаю контейнер $name (держит 80/443)" docker stop "$name" || true fi done else warn "Caddy может не получить SSL, пока 80/443 заняты" fi fi } configure_firewall() { title "Firewall" if ! command -v ufw >/dev/null 2>&1; then ask_yn "UFW не установлен. Поставить и открыть 22/80/443?" "y" if [[ "$REPLY" == "y" ]]; then apt-get install -y ufw else return fi fi ask_yn "Настроить UFW: разрешить 22, 80, 443?" "y" if [[ "$REPLY" != "y" ]]; then return fi ufw allow OpenSSH >/dev/null 2>&1 || ufw allow 22/tcp >/dev/null 2>&1 || true ufw allow 80/tcp >/dev/null 2>&1 || true ufw allow 443/tcp >/dev/null 2>&1 || true ufw allow 443/udp >/dev/null 2>&1 || true echo "y" | ufw enable >/dev/null 2>&1 || true log "UFW: 22 / 80 / 443 открыты" } collect_config() { title "Настройки проекта" ask "Домен Mini App (A-запись → IP этого сервера)" "bot.example.com" WEBAPP_DOMAIN="${REPLY,,}" WEBAPP_DOMAIN="${WEBAPP_DOMAIN#https://}" WEBAPP_DOMAIN="${WEBAPP_DOMAIN#http://}" WEBAPP_DOMAIN="${WEBAPP_DOMAIN%%/*}" WEBAPP_URL="https://${WEBAPP_DOMAIN}" ask "Email для Let's Encrypt (Caddy, необязательно)" "" ACME_EMAIL="$REPLY" echo info "Remnawave панель" ask "REMNAWAVE_BASE_URL" "https://panel.example.com" REMNAWAVE_BASE_URL="${REPLY%/}" ask_yn "Использовать API Token панели? (иначе логин/пароль)" "y" if [[ "$REPLY" == "y" ]]; then ask_secret "REMNAWAVE_TOKEN" REMNAWAVE_TOKEN="$REPLY" REMNAWAVE_USERNAME="" REMNAWAVE_PASSWORD="" while [[ -z "$REMNAWAVE_TOKEN" ]]; do warn "Токен обязателен" ask_secret "REMNAWAVE_TOKEN" REMNAWAVE_TOKEN="$REPLY" done else REMNAWAVE_TOKEN="" ask "REMNAWAVE_USERNAME" "admin" REMNAWAVE_USERNAME="$REPLY" ask_secret "REMNAWAVE_PASSWORD" REMNAWAVE_PASSWORD="$REPLY" fi echo info "Telegram бот (@BotFather)" ask_secret "BOT_TOKEN" BOT_TOKEN="$REPLY" while [[ -z "$BOT_TOKEN" ]]; do warn "BOT_TOKEN обязателен" ask_secret "BOT_TOKEN" BOT_TOKEN="$REPLY" done ask "ADMIN_IDS (Telegram ID через запятую)" "" ADMIN_IDS="$REPLY" echo info "PostgreSQL (контейнер в Docker Compose)" ask "POSTGRES_USER" "vpnbot" POSTGRES_USER="$REPLY" GEN_PG="$(rand_pass)" ask_secret "POSTGRES_PASSWORD (Enter = сгенерировать)" "$GEN_PG" POSTGRES_PASSWORD="$REPLY" ask "POSTGRES_DB" "vpnbot" POSTGRES_DB="$REPLY" echo info "Веб-админка https://${WEBAPP_DOMAIN}/admin" ask "WEB_ADMIN_LOGIN" "admin" WEB_ADMIN_LOGIN="$REPLY" GEN_ADM="$(rand_pass)" ask_secret "WEB_ADMIN_PASSWORD (Enter = сгенерировать)" "$GEN_ADM" WEB_ADMIN_PASSWORD="$REPLY" echo ask_yn "Включить Telegram Stars для пополнения?" "y" if [[ "$REPLY" == "y" ]]; then STARS_ENABLED="true" ask "Курс ₽ за 1⭐" "1.5" STARS_RUB_PER_STAR="$REPLY" else STARS_ENABLED="false" STARS_RUB_PER_STAR="1.5" fi ask "Ссылка покупки кода пополнения (TOPUP_CODE_PAY_URL, можно пусто)" "" TOPUP_CODE_PAY_URL="$REPLY" echo ask_yn "Настроить SMTP сейчас? (можно позже в /admin → SMTP)" "n" if [[ "$REPLY" == "y" ]]; then ask "SMTP_HOST" "" SMTP_HOST="$REPLY" ask "SMTP_PORT" "587" SMTP_PORT="$REPLY" ask "SMTP_USER" "" SMTP_USER="$REPLY" ask_secret "SMTP_PASSWORD" SMTP_PASSWORD="$REPLY" ask "SMTP_FROM" "" SMTP_FROM="$REPLY" ask_yn "SMTP STARTTLS?" "y" [[ "$REPLY" == "y" ]] && SMTP_TLS="true" || SMTP_TLS="false" ask_yn "SMTP SSL (порт 465)?" "n" [[ "$REPLY" == "y" ]] && SMTP_SSL="true" || SMTP_SSL="false" else SMTP_HOST="" SMTP_PORT="587" SMTP_USER="" SMTP_PASSWORD="" SMTP_FROM="" SMTP_TLS="true" SMTP_SSL="false" fi echo warn "Платёжные ключи (Crypto/Lava/Heleket/…) можно заполнить позже в веб-админке." ask_yn "Продолжить установку?" "y" if [[ "$REPLY" != "y" ]]; then err "Отменено" exit 1 fi } write_env() { title "Запись .env" local env_file="${SCRIPT_DIR}/.env" if [[ -f "$env_file" ]]; then local bak="${env_file}.bak.$(date +%Y%m%d-%H%M%S)" cp -a "$env_file" "$bak" warn "Старый .env сохранён: $bak" fi cat > "$env_file" <> "$env_file" fi chmod 600 "$env_file" log ".env записан: $env_file" } ensure_files() { title "Проверка файлов проекта" local need=(docker-compose.yml Dockerfile requirements.txt bot.py tariffs.json caddy/Caddyfile) local missing=0 for f in "${need[@]}"; do if [[ ! -e "${SCRIPT_DIR}/${f}" ]]; then err "Нет файла: $f" missing=1 fi done if [[ "$missing" -eq 1 ]]; then err "Запускай install.sh из корня репозитория VPN Service Bot" exit 1 fi log "Файлы проекта на месте" } update_caddyfile_email() { if [[ -z "${ACME_EMAIL:-}" ]]; then return fi if grep -qE '^\s*email\s+' "${SCRIPT_DIR}/caddy/Caddyfile" 2>/dev/null; then log "Caddyfile уже содержит email — не трогаем" return fi local caddy_bak="${SCRIPT_DIR}/caddy/Caddyfile.bak.$(date +%Y%m%d-%H%M%S)" cp -a "${SCRIPT_DIR}/caddy/Caddyfile" "$caddy_bak" cat > "${SCRIPT_DIR}/caddy/Caddyfile" </dev/null 2>&1; then ok=1 break fi sleep 2 done if [[ "$ok" -eq 1 ]]; then log "API health OK (внутри контейнера)" else warn "Health пока не ответил — смотри: docker compose logs -f bot" fi } print_done() { title "Готово" echo -e "${GREEN}Установка завершена.${NC}" echo echo " Кабинет: ${WEBAPP_URL}" echo " Админка: ${WEBAPP_URL}/admin" echo " Логин админа: ${WEB_ADMIN_LOGIN}" echo " Пароль админа: (тот, что задал / сгенерирован)" echo echo "Дальше:" echo " 1) @BotFather → /setdomain → ${WEBAPP_DOMAIN}" echo " 2) @BotFather → Menu Button → Web App → ${WEBAPP_URL}" echo " 3) Платежи: ${WEBAPP_URL}/admin → Платежи" echo " 4) SMTP: ${WEBAPP_URL}/admin → SMTP" echo " 5) Рост: ${WEBAPP_URL}/admin → Рост (промо, trial, бэкапы)" echo echo "Бэкапы Postgres (cron, опционально):" echo " 0 3 * * * cd ${SCRIPT_DIR} && docker compose exec -T -e PGPASSWORD=\$POSTGRES_PASSWORD db pg_dump -U \$POSTGRES_USER -d \$POSTGRES_DB | gzip > backups/vpnbot-\$(date +\\%F).sql.gz" echo echo "Команды:" echo " cd ${SCRIPT_DIR}" echo " docker compose ps" echo " docker compose logs -f bot" echo " docker compose logs -f caddy" echo " docker compose up -d --build" echo } main() { clear 2>/dev/null || true echo -e "${BOLD}" echo " VPN Service Bot — установщик" echo " Docker · PostgreSQL · Caddy (Let's Encrypt SSL)" echo -e "${NC}" if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then cat <<'HELP' Использование: sudo bash install.sh Интерактивная установка sudo bash install.sh --reinstall Пересобрать и перезапустить без опроса sudo bash install.sh --env-only Только пересоздать .env (опрос), без Docker up Стек: db (Postgres 17) + bot + caddy (80/443, авто-SSL). HELP exit 0 fi need_root check_os ensure_files if [[ "${1:-}" == "--reinstall" ]]; then if [[ ! -f "${SCRIPT_DIR}/.env" ]]; then err "Нет .env — сначала полный install.sh" exit 1 fi # shellcheck disable=SC1091 set -a # shellcheck source=/dev/null source "${SCRIPT_DIR}/.env" set +a WEBAPP_DOMAIN="${WEBAPP_DOMAIN:-}" WEBAPP_URL="${WEBAPP_URL:-https://${WEBAPP_DOMAIN}}" WEB_ADMIN_LOGIN="${WEB_ADMIN_LOGIN:-admin}" install_docker start_stack print_done exit 0 fi install_docker check_ports configure_firewall collect_config write_env update_caddyfile_email if [[ "${1:-}" == "--env-only" ]]; then log "Только .env — контейнеры не трогали" echo "Запуск: docker compose up -d --build" exit 0 fi dns_hint start_stack print_done } main "$@"