gendesign/ops/selectel-bootstrap.sh
bot-backend 05e82989a8
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(ops): закрепить рабочий адрес api.telegram.org на новом хосте
Замер 2026-08-23 с Poincare: у api.telegram.org семь публикуемых адресов,
отвечает РОВНО ОДИН — 149.154.167.220 (302 за 0.11 с). Резолвер при этом
отдаёт 149.154.166.110, который мёртв. Скан всего 149.154.167.0/24
подтвердил: живой он один во всём блоке.

Это не блокировка Selectel и не наш файрвол: с Beget отвечают три адреса
из тех же семи, включая тот, что отдаёт DNS там. Telegram частично
фильтруется у ОБОИХ провайдеров, просто Beget попадает на живой адрес.
Через прокси ASocks Telegram не проходит ни с одного из четырёх узлов
(все 000) — узлы российские, путь закрыт.

Без закрепления tradein-tgbot на long-polling резолвит мёртвый адрес,
виснет и умирает МОЛЧА: restart поднимает его заново, он снова виснет,
ни краша, ни строки в логе.

Закреплены все три потребителя Telegram, а не только бот:
- tgbot и backend — extra_hosts в новом docker-compose.selectel.yml.
  backend тоже ходит в Telegram: пересылка алертов GlitchTip
  (api/v1/glitchtip.py) и support-чат (api/v1/support.py).
- хостовые скрипты ops/lib-backup.sh и ops/uptime-healthcheck.sh —
  шаг 11 в selectel-bootstrap.sh кладёт запись в /etc/hosts.
  Им compose не помогает, они идут из cron, не из контейнера.

Отдельный override-файл, а не правка docker-compose.prod.yml: на Beget
закрепление не нужно, и менять поведение действующего прода ради
будущего хоста нельзя. Файл просто не передаётся в -f.

Адрес вынесен в TELEGRAM_API_IP с текущим дефолтом — если он умрёт,
правка в одну переменную окружения без релиза. Шаг bootstrap проверяет
не факт записи, а что Bot API отвечает 401 на фиктивный токен, и при
другом коде печатает команду поиска нового живого адреса.

Проверено: docker compose config даёт ровно две вставки extra_hosts
(tradein-backend, tradein-tgbot) и ничего больше; TELEGRAM_API_IP
подставляется в обе.

Refs #3059, #3057
2026-08-23 23:32:35 +03:00

260 lines
14 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# Первичная настройка нового выделенного сервера (Selectel AR32-NVMe, Ubuntu 24.04).
#
# Идемпотентен: повторный запуск ничего не ломает и не дублирует.
# Ничего не удаляет и не переразмечает диски.
#
# ВАЖНО: сначала прогнать ops/selectel-audit.sh и убедиться, что диски в зеркале.
# Если зеркала нет — ОС переставляется ДО этого скрипта, иначе всю настройку
# придётся выкидывать вместе с переустановкой.
#
# Порядок внутри выбран так, чтобы не потерять доступ: правило для SSH в файрвол
# добавляется ДО его включения, парольный вход выключается ПОСЛЕ проверки, что
# ключ работает.
#
# Запуск (от root): ssh root@<ip> 'bash -s' < ops/selectel-bootstrap.sh
#
# Переменные окружения:
# ADMIN_USER — создаваемый пользователь (по умолчанию gendesign)
# ADMIN_PUBKEY — публичный ключ для него (обязателен при создании)
# SSH_PORT — порт sshd (по умолчанию 22)
# SWAP_GB — размер файла подкачки (по умолчанию 8)
# TIMEZONE — часовой пояс (по умолчанию Europe/Moscow, как на текущем проде)
set -euo pipefail
ADMIN_USER="${ADMIN_USER:-gendesign}"
ADMIN_PUBKEY="${ADMIN_PUBKEY:-}"
SSH_PORT="${SSH_PORT:-22}"
SWAP_GB="${SWAP_GB:-8}"
# Версии на текущем прод-хосте — ориентир для сверки, НЕ то, что ставится.
# Репозиторий Docker отдаёт последнюю версию; закреплять старую ради совпадения
# значило бы отказаться от исправлений безопасности. Расхождение печатается
# предупреждением, чтобы оно не прошло незамеченным (22.08.2026 приехали
# docker 29.7.2 и compose 5.5.0 против 29.4.1 / v5.1.3 на Beget).
WANT_DOCKER="29.4.1"
WANT_COMPOSE="v5.1.3"
log() { printf '\n\033[1m>>> %s\033[0m\n' "$1"; }
warn() { printf '\033[33m[!] %s\033[0m\n' "$1"; }
die() { printf '\033[31m[x] %s\033[0m\n' "$1" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "запускать от root"
log "0. Проверка предусловий"
if [ ! -f /proc/mdstat ] || ! grep -q 'raid1' /proc/mdstat 2>/dev/null; then
warn "RAID1 в /proc/mdstat не найден."
warn "Память на этой платформе non-ECC, диска два — без зеркала отказ одного"
warn "диска означает потерю базы. Убедись, что это осознанное решение."
warn "Продолжение через 15 секунд, Ctrl-C чтобы прервать."
sleep 15
fi
log "1. Базовые пакеты"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq \
ca-certificates curl gnupg git ufw fail2ban unattended-upgrades \
htop ncdu jq nvme-cli smartmontools chrony >/dev/null
log "2. Часовой пояс"
# Совпадает с текущим прод-хостом (Beget = Europe/Moscow, проверено 22.08.2026).
# Ставить UTC нельзя без правки crontab: бэкапы и джобы записаны в местном
# времени, смена пояса сдвинула бы все шесть задач на три часа.
timedatectl set-timezone "${TIMEZONE:-Europe/Moscow}"
systemctl enable --now chrony >/dev/null 2>&1 || true
timedatectl | head -3
log "3. Пользователь $ADMIN_USER"
if id "$ADMIN_USER" >/dev/null 2>&1; then
echo "уже существует"
else
[ -n "$ADMIN_PUBKEY" ] || die "ADMIN_PUBKEY не задан — иначе доступа к серверу не будет"
adduser --disabled-password --gecos "" "$ADMIN_USER"
echo "создан"
fi
if [ -n "$ADMIN_PUBKEY" ]; then
install -d -m 700 -o "$ADMIN_USER" -g "$ADMIN_USER" "/home/$ADMIN_USER/.ssh"
touch "/home/$ADMIN_USER/.ssh/authorized_keys"
grep -qxF "$ADMIN_PUBKEY" "/home/$ADMIN_USER/.ssh/authorized_keys" \
|| echo "$ADMIN_PUBKEY" >> "/home/$ADMIN_USER/.ssh/authorized_keys"
chmod 600 "/home/$ADMIN_USER/.ssh/authorized_keys"
chown -R "$ADMIN_USER:$ADMIN_USER" "/home/$ADMIN_USER/.ssh"
echo "ключ на месте"
fi
usermod -aG sudo "$ADMIN_USER"
echo "$ADMIN_USER ALL=(ALL) NOPASSWD:ALL" > "/etc/sudoers.d/90-$ADMIN_USER"
chmod 440 "/etc/sudoers.d/90-$ADMIN_USER"
log "4. Файрвол (правило для SSH — до включения)"
ufw --force reset >/dev/null
ufw default deny incoming >/dev/null
ufw default allow outgoing >/dev/null
ufw allow "$SSH_PORT/tcp" comment 'ssh' >/dev/null
ufw allow 80/tcp comment 'http' >/dev/null
ufw allow 443/tcp comment 'https' >/dev/null
ufw --force enable >/dev/null
ufw status verbose
log "5. Docker $WANT_DOCKER + Compose $WANT_COMPOSE"
if ! command -v docker >/dev/null 2>&1; then
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin >/dev/null
fi
usermod -aG docker "$ADMIN_USER"
systemctl enable --now docker >/dev/null
have_docker="$(docker --version | grep -oP '\d+\.\d+\.\d+' | head -1)"
have_compose="$(docker compose version --short 2>/dev/null || echo '?')"
echo "docker $have_docker · compose $have_compose"
[ "$have_docker" = "$WANT_DOCKER" ] || warn "docker $have_docker != $WANT_DOCKER на текущем проде"
case "$have_compose" in "${WANT_COMPOSE#v}"*) ;; *) warn "compose $have_compose != $WANT_COMPOSE" ;; esac
log "6. Журналы Docker — чтобы диск не съело логами"
if [ ! -f /etc/docker/daemon.json ]; then
install -d /etc/docker
cat > /etc/docker/daemon.json <<'JSON'
{
"log-driver": "json-file",
"log-opts": { "max-size": "50m", "max-file": "3" },
"live-restore": true
}
JSON
systemctl restart docker
echo "daemon.json создан"
else
echo "daemon.json уже есть — не трогаю"
fi
log "7. Swap ${SWAP_GB}G"
# Установщик Selectel уже отдаёт swap-раздел (на AR32-NVMe — 4.7G в зеркале md).
# Добавлять файл поверх раздела незачем: памяти 62G, своп тут страховка.
if swapon --show --noheadings 2>/dev/null | grep -q .; then
echo "swap уже есть, файл не создаю:"
swapon --show
elif swapon --show | grep -q '/swapfile'; then
echo "уже подключён"
else
fallocate -l "${SWAP_GB}G" /swapfile || dd if=/dev/zero of=/swapfile bs=1M count=$((SWAP_GB*1024))
chmod 600 /swapfile
mkswap /swapfile >/dev/null
swapon /swapfile
grep -q '^/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab
echo "создан"
fi
cat > /etc/sysctl.d/60-gendesign.conf <<'SYSCTL'
# Памяти 64 ГБ — своп только страховка, в него лезть не надо.
vm.swappiness = 10
# Redis просит overcommit, иначе фоновое сохранение может не запуститься.
vm.overcommit_memory = 1
# Больше открытых файлов: два десятка контейнеров + Postgres.
fs.file-max = 2097152
SYSCTL
sysctl --system >/dev/null
sysctl vm.swappiness vm.overcommit_memory
log "8. Автообновления безопасности"
cat > /etc/apt/apt.conf.d/20auto-upgrades <<'APT'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT
systemctl enable --now unattended-upgrades >/dev/null 2>&1 || true
log "9. fail2ban на sshd"
cat > /etc/fail2ban/jail.d/sshd.local <<JAIL
[sshd]
enabled = true
port = $SSH_PORT
maxretry = 5
bantime = 1h
findtime = 10m
JAIL
systemctl enable --now fail2ban >/dev/null
systemctl is-active fail2ban
log "10. Ужесточение sshd"
# Имя начинается с 00, а не с 60, намеренно: sshd берёт ПЕРВОЕ встреченное
# значение директивы, а Ubuntu кладёт рядом 50-cloud-init.conf с
# PasswordAuthentication yes. При имени 60-* парольный вход молча остаётся
# включённым, и `sshd -t` при этом рапортует, что всё в порядке.
rm -f /etc/ssh/sshd_config.d/60-hardening.conf
cat > /etc/ssh/sshd_config.d/00-hardening.conf <<SSHD
Port $SSH_PORT
PermitRootLogin prohibit-password
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitEmptyPasswords no
MaxAuthTries 4
ClientAliveInterval 60
SSHD
sshd -t || die "конфиг sshd невалиден — НЕ перезапускаю, доступ сохранён"
echo "конфиг валиден"
# Синтаксическая валидность ничего не говорит о том, какое значение победило.
# Проверяем итог так, как его увидит sshd после перезапуска.
eff_pass="$(sshd -T 2>/dev/null | awk '/^passwordauthentication /{print $2}')"
eff_root="$(sshd -T 2>/dev/null | awk '/^permitrootlogin /{print $2}')"
echo "итог: passwordauthentication=$eff_pass permitrootlogin=$eff_root"
[ "$eff_pass" = "no" ] || die "PasswordAuthentication остался '$eff_pass' — какой-то файл в sshd_config.d перебивает наш; НЕ перезапускаю"
log "11. Закрепление рабочего адреса api.telegram.org"
# Замер 2026-08-23 с этого хоста: у api.telegram.org семь публикуемых адресов,
# отвечает РОВНО ОДИН — 149.154.167.220. Резолвер при этом отдаёт 149.154.166.110,
# который мёртв. Скан всего 149.154.167.0/24 подтвердил: живой он один во всём блоке.
# Это не блокировка Selectel — с Beget отвечают три адреса из тех же семи, просто
# там штатный резолвер попадает на живой. Через прокси ASocks Telegram тоже не
# проходит (все четыре узла — 000), так что обход только через закрепление IP.
#
# Контейнерам это даёт tradein-mvp/docker-compose.selectel.yml (extra_hosts для
# tgbot и backend). Здесь — для ХОСТОВЫХ скриптов, которым compose не помогает:
# ops/lib-backup.sh (уведомления о бэкапах) и ops/uptime-healthcheck.sh.
# Без этого они молча перестают слать алерты — а это ровно тот канал, которым
# мы узнали бы о любой другой поломке.
TELEGRAM_API_IP="${TELEGRAM_API_IP:-149.154.167.220}"
if grep -qE '^[0-9.]+[[:space:]]+api\.telegram\.org$' /etc/hosts; then
sed -i -E "s|^[0-9.]+([[:space:]]+api\.telegram\.org)$|${TELEGRAM_API_IP}\1|" /etc/hosts
echo "запись обновлена -> ${TELEGRAM_API_IP}"
else
echo "${TELEGRAM_API_IP} api.telegram.org" >> /etc/hosts
echo "запись добавлена -> ${TELEGRAM_API_IP}"
fi
# Проверяем не сам факт записи, а что Bot API реально отвечает: 401 на заведомо
# фиктивный токен означает, что до API достучались и он нас понял.
tg_code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 \
https://api.telegram.org/bot000000:FakeProbeToken/getMe 2>/dev/null || echo 000)"
if [ "$tg_code" = "401" ]; then
echo "проверка: Bot API отвечает (401 на фиктивный токен) — закрепление работает"
else
echo "ВНИМАНИЕ: Bot API вернул '$tg_code' вместо 401 — адрес ${TELEGRAM_API_IP} мог умереть."
echo " Найди живой: for ip in \$(seq 1 254); do curl -s -o /dev/null --max-time 3 \\"
echo " --resolve api.telegram.org:443:149.154.167.\$ip -w \"149.154.167.\$ip %{http_code}\\n\" \\"
echo " https://api.telegram.org/; done | grep 302"
echo " и пропиши его в TELEGRAM_API_IP (здесь и в .env.runtime для контейнеров)."
fi
cat <<EOF
============================================================
Настройка завершена. sshd ещё НЕ перезапущен намеренно.
Прежде чем перезапускать, в ОТДЕЛЬНОМ окне проверь вход по ключу:
ssh -p $SSH_PORT $ADMIN_USER@\$(hostname -I | awk '{print \$1}')
Только когда вход по ключу работает:
systemctl reload ssh
Если перезапустить раньше и ключ не подойдёт, парольный вход уже
выключён — останется только IP-KVM за 1320 руб.
============================================================
EOF