Files
vpn-telegram-bot/install.sh
T

575 lines
17 KiB
Bash

#!/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" <<EOF
# Сгенерировано install.sh $(date -u +%Y-%m-%dT%H:%M:%SZ)
REMNAWAVE_BASE_URL=$(env_escape "$REMNAWAVE_BASE_URL")
REMNAWAVE_TOKEN=$(env_escape "$REMNAWAVE_TOKEN")
REMNAWAVE_USERNAME=$(env_escape "$REMNAWAVE_USERNAME")
REMNAWAVE_PASSWORD=$(env_escape "$REMNAWAVE_PASSWORD")
BOT_TOKEN=$(env_escape "$BOT_TOKEN")
ADMIN_IDS=$(env_escape "$ADMIN_IDS")
STARS_ENABLED=${STARS_ENABLED}
STARS_RUB_PER_STAR=${STARS_RUB_PER_STAR}
POSTGRES_USER=$(env_escape "$POSTGRES_USER")
POSTGRES_PASSWORD=$(env_escape "$POSTGRES_PASSWORD")
POSTGRES_DB=$(env_escape "$POSTGRES_DB")
WEBAPP_URL=$(env_escape "$WEBAPP_URL")
WEBAPP_DOMAIN=$(env_escape "$WEBAPP_DOMAIN")
WEBAPP_HOST=0.0.0.0
WEBAPP_PORT=8080
WEB_ADMIN_LOGIN=$(env_escape "$WEB_ADMIN_LOGIN")
WEB_ADMIN_PASSWORD=$(env_escape "$WEB_ADMIN_PASSWORD")
TOPUP_CODE_PAY_URL=$(env_escape "$TOPUP_CODE_PAY_URL")
CRYPTO_BOT_TOKEN=
CRYPTO_PAY_TESTNET=false
HELEKET_MERCHANT_ID=
HELEKET_API_KEY=
LAVA_SHOP_ID=
LAVA_SECRET_KEY=
LAVA_ADDITIONAL_KEY=
ANYPAY_API_ID=
ANYPAY_API_KEY=
ANYPAY_PROJECT_ID=
DIGISELLER_SELLER_ID=
DIGISELLER_API_KEY=
SMTP_HOST=$(env_escape "$SMTP_HOST")
SMTP_PORT=${SMTP_PORT}
SMTP_USER=$(env_escape "$SMTP_USER")
SMTP_PASSWORD=$(env_escape "$SMTP_PASSWORD")
SMTP_FROM=$(env_escape "$SMTP_FROM")
SMTP_TLS=${SMTP_TLS}
SMTP_SSL=${SMTP_SSL}
EOF
if [[ -n "${ACME_EMAIL:-}" ]]; then
echo "ACME_EMAIL=$(env_escape "$ACME_EMAIL")" >> "$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" <<EOF
{
email ${ACME_EMAIL}
}
{\$WEBAPP_DOMAIN:bot.example.com} {
encode gzip
reverse_proxy bot:8080
header {
X-Content-Type-Options nosniff
Referrer-Policy no-referrer-when-downgrade
}
log {
output stdout
}
}
EOF
log "Caddyfile: email для ACME = ${ACME_EMAIL} (бэкап: $(basename "$caddy_bak"))"
}
dns_hint() {
title "DNS"
local ip
ip="$(curl -4 -fsS --max-time 5 https://ifconfig.me 2>/dev/null || curl -4 -fsS --max-time 5 https://api.ipify.org 2>/dev/null || echo "?")"
info "Публичный IP сервера: ${ip}"
info "Создай A-запись: ${WEBAPP_DOMAIN}${ip}"
ask_yn "DNS уже указывает на этот сервер?" "y"
if [[ "$REPLY" != "y" ]]; then
warn "Без DNS Let's Encrypt не выдаст сертификат. Можно продолжить — Caddy повторит позже."
fi
}
start_stack() {
title "Сборка и запуск (Docker Compose)"
cd "$SCRIPT_DIR"
docker compose pull db caddy || true
docker compose up -d --build
log "Контейнеры запущены"
echo
docker compose ps
echo
info "Ждём health…"
sleep 5
local ok=0
for _ in $(seq 1 30); do
if docker compose exec -T bot curl -fsS http://127.0.0.1:8080/api/health >/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 "$@"