#!/usr/bin/env bash # Интерактивная установка «Расписания» на чистый сервер. # # Запуск из распакованного каталога проекта (нужен root — скрипт сам # перезапустится через sudo): # # bash install.sh # # Скрипт спросит: как ставить (Docker или прямо в систему), публичную ссылку # на таблицу и домен сайта. Затем поставит всё нужное, запустит сервисы и # настроит nginx с сертификатом Let's Encrypt. # # Для автоматизации ответы можно передать аргументами: # --mode docker|system способ установки # --link URL публичная ссылка Яндекс.Диска с таблицей # --domain DOMAIN домен сайта (пусто = без nginx, сайт на 127.0.0.1) # --api-port N порт api (по умолчанию 8000) # --front-port N порт сайта (по умолчанию 8080) # --user NAME пользователь служб (режим «в систему») # --email ADDR почта для уведомлений Let's Encrypt # --dry-run показать план установки, ничего не меняя set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } err() { printf '\033[31m==>\033[0m %s\n' "$*" >&2; } die() { err "$*"; exit 1; } usage() { grep '^#' "$0" | sed 's/^# \{0,1\}//' | tail -n +2; } MODE="" LINK="" DOMAIN="" DOMAIN_SET=0 API_PORT="" FRONT_PORT="" RUN_USER="" EMAIL="" DRY_RUN=0 while [[ $# -gt 0 ]]; do case "$1" in --mode) MODE="$2"; shift 2 ;; --link) LINK="$2"; shift 2 ;; --domain) DOMAIN="$2"; DOMAIN_SET=1; shift 2 ;; --api-port) API_PORT="$2"; shift 2 ;; --front-port) FRONT_PORT="$2"; shift 2 ;; --user) RUN_USER="$2"; shift 2 ;; --email) EMAIL="$2"; shift 2 ;; --dry-run) DRY_RUN=1; shift ;; -h|--help) usage; exit 0 ;; *) die "Неизвестный аргумент: $1 (см. --help)" ;; esac done # ask "Вопрос" [по_умолчанию] — ответ в $REPLY ask() { local def="${2:-}" input if ! read -rp "$1${def:+ [$def]}: " input; then die "Нужен интерактивный ввод — запустите в терминале или передайте все параметры флагами (см. --help)" fi REPLY="${input:-$def}" } # --- вопросы ------------------------------------------------------------------- if [[ -z $MODE ]]; then log "Как ставить?" log " 1) Docker — изолированные контейнеры, автоперезапуск, обновление одной командой (рекомендуется)" log " 2) Система — без Docker: venv + службы systemd" while true; do ask "Способ установки (docker/system)" "docker" REPLY=$(echo "$REPLY" | tr '[:upper:]' '[:lower:]' | xargs) if [[ $REPLY == 1 ]]; then REPLY=docker elif [[ $REPLY == 2 ]]; then REPLY=system fi [[ $REPLY =~ ^(docker|system)$ ]] && break || err "Ответьте docker или system" done MODE=$REPLY fi [[ $MODE =~ ^(docker|system)$ ]] || die "--mode: только docker или system" if [[ -z $LINK ]]; then while true; do ask "Публичная ссылка на таблицу (Яндекс.Диск)" REPLY=$(echo "$REPLY" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | tr -d '"') [[ -n $REPLY ]] && break || err "Без ссылки приложение не сможет скачать расписание" done LINK=$REPLY fi if [[ $DOMAIN_SET == 0 && -z $DOMAIN ]]; then ask "Домен сайта, например sched.example.ru (пусто = без nginx, сайт только на 127.0.0.1)" DOMAIN=$(echo "$REPLY" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') fi if [[ -n $DOMAIN && ! $DOMAIN =~ ^[a-z0-9.-]+$ ]]; then die "Кривое имя домена: $DOMAIN"; fi if [[ -z $API_PORT ]]; then ask "Порт api (нужен только для диагностики)" "8000" API_PORT=$REPLY fi if [[ -z $FRONT_PORT ]]; then ask "Порт сайта" "8080" FRONT_PORT=$REPLY fi [[ $API_PORT =~ ^[0-9]+$ && $FRONT_PORT =~ ^[0-9]+$ ]] || die "Порты должны быть числами" if [[ $MODE == system ]]; then if [[ -z $RUN_USER ]]; then ask "Пользователь, от которого будут работать службы" "${SUDO_USER:-$(id -un)}" RUN_USER=$REPLY fi id "$RUN_USER" >/dev/null 2>&1 || die "Пользователь $RUN_USER не существует" fi if [[ -n $DOMAIN && -z $EMAIL ]]; then ask "Почта для уведомлений Let's Encrypt" "admin@$DOMAIN" EMAIL=$REPLY fi if [[ $DRY_RUN == 1 ]]; then log "План установки (dry-run, ничего не выполнено):" echo " режим: $MODE" echo " таблица: $LINK" echo " домен: ${DOMAIN:-(не задан — nginx и https пропускаются, сайт на 127.0.0.1)}" echo " порты: api $API_PORT, сайт $FRONT_PORT" if [[ $MODE == system ]]; then echo " службы от: $RUN_USER" fi if [[ -n $DOMAIN ]]; then echo " сертификат: Let's Encrypt для $DOMAIN (почта: $EMAIL)" fi exit 0 fi # --- права ---------------------------------------------------------------------- if [[ $EUID -ne 0 ]]; then if [[ ${INSTALL_NO_SUDO:-0} == 1 ]]; then err "Запущено без root (INSTALL_NO_SUDO=1) — часть шагов может не пройти" else log "Нужны права root — перезапускаюсь через sudo" exec sudo bash "$0" "$@" fi fi # --- помощники ------------------------------------------------------------------ pkg_install() { local missing=() p for p in "$@"; do command -v "$p" >/dev/null 2>&1 || missing+=("$p"); done if [[ ${#missing[@]} -eq 0 ]]; then return 0 fi log "Ставлю пакеты: ${missing[*]}" if command -v apt-get >/dev/null 2>&1; then apt-get update -y DEBIAN_FRONTEND=noninteractive apt-get install -y "${missing[@]}" elif command -v dnf >/dev/null 2>&1; then dnf install -y "${missing[@]}" else die "Не нашёл apt-get/dnf — поставьте вручную: ${missing[*]}" fi } wait_health() { local url="$1" name="$2" i printf '%s' "==> Жду $name ($url)" for i in $(seq 1 60); do if curl -sf -m 2 "$url" >/dev/null 2>&1; then echo " — готово"; return 0; fi printf '.'; sleep 1 done echo die "$name не отвечает на $url — смотрите логи (docker logs / journalctl)" } write_env() { cd "$ROOT" if [[ -f .env && ! -f .env.install-backup ]]; then cp .env .env.install-backup fi cat > .env < ADMIN_PATH= ADMIN_LOGIN= ADMIN_PASSWORD= ADMIN_MAX_ATTEMPTS=3 ADMIN_LOCKOUT_MINUTES=15 EOF log ".env записан (прежний, если был, сохранён в .env.install-backup)" } install_docker() { if command -v docker >/dev/null 2>&1; then log "Docker уже установлен" else log "Ставлю Docker..." curl -fsSL https://get.docker.com | sh fi if [[ $EUID -eq 0 ]]; then systemctl enable --now docker fi } setup_nginx() { pkg_install nginx certbot curl if [[ ! -d /etc/nginx/sites-available ]]; then die "Нужен nginx с поддержкой sites-available (Debian/Ubuntu); на других системах настройте конфиг по README" fi log "Временный http-конфиг $DOMAIN — для выпуска сертификата" cat > "/etc/nginx/sites-available/$DOMAIN" <<'NGINX' # Сгенерировано install.sh: временный конфиг для certbot, потом заменяется server { listen 80; listen [::]:80; server_name __DOMAIN__; root /var/www/html; location /.well-known/acme-challenge/ { } } NGINX sed -i "s/__DOMAIN__/$DOMAIN/" "/etc/nginx/sites-available/$DOMAIN" ln -sf "/etc/nginx/sites-available/$DOMAIN" "/etc/nginx/sites-enabled/$DOMAIN" nginx -t && systemctl reload nginx log "Выпускаю сертификат Let's Encrypt для $DOMAIN (webroot, без остановки nginx)" certbot certonly --webroot -w /var/www/html -d "$DOMAIN" \ --non-interactive --agree-tos -m "$EMAIL" log "Финальный конфиг сайта: https + прокси на 127.0.0.1:$FRONT_PORT" "$ROOT/deploy/nginx-site.sh" --domain "$DOMAIN" --cert "$DOMAIN" --port "$FRONT_PORT" } # --- установка ------------------------------------------------------------------ if [[ $MODE == docker ]]; then install_docker write_env log "Собираю и запускаю контейнеры (первая сборка — несколько минут)..." (cd "$ROOT" && docker compose up -d --build) wait_health "http://127.0.0.1:$FRONT_PORT/healthz" "frontend" else log "Проверяю Python..." pkg_install python3 python3-pip if command -v apt-get >/dev/null 2>&1; then apt-get install -y python3-venv # на Debian/Ubuntu venv без этого пакета не работает fi python3 - <<'PY' || die "Нужен Python 3.11 или новее (см. README, «Зависимости»)" import sys sys.exit(0 if sys.version_info >= (3, 11) else 1) PY write_env log "Создаю виртуальное окружение и ставлю зависимости..." [[ -d "$ROOT/.venv" ]] || python3 -m venv "$ROOT/.venv" "$ROOT/.venv/bin/pip" install -q --upgrade pip "$ROOT/.venv/bin/pip" install -q -r "$ROOT/requirements.txt" log "Настраиваю службы systemd..." cat > /etc/systemd/system/schedule-api.service < /etc/systemd/system/schedule-frontend.service <