gendesign/ops/selectel-bootstrap.sh
bot-backend fb75bce22d
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
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): hardening sshd молча не применялся из-за приоритета cloud-init
Найдено при первом живом запуске на Poincare.

Ubuntu кладёт в /etc/ssh/sshd_config.d/ файл 50-cloud-init.conf с
PasswordAuthentication yes. sshd берёт ПЕРВОЕ встреченное значение директивы,
а не последнее, поэтому наш 60-hardening.conf проигрывал по имени: после
reload парольный вход для root остался бы открытым.

Коварство в том, что `sshd -t` при этом отвечает «конфиг валиден», и скрипт
рапортовал об успехе. Сервер уже под перебором (fail2ban держал 6 адресов),
так что тихий отказ здесь стоил бы дорого.

Три правки:
- файл называется 00-hardening.conf и читается первым; старый 60-* удаляется,
  чтобы повторный запуск не оставил два конфликтующих;
- после записи проверяется не синтаксис, а ИТОГ через `sshd -T`: если
  passwordauthentication != no, скрипт падает и НЕ перезапускает sshd;
- комментарий про версии Docker приведён в соответствие с поведением —
  ставится последняя из репозитория, версии прода служат ориентиром для
  предупреждения (приехали 29.7.2 / 5.5.0 против 29.4.1 / v5.1.3).
2026-08-22 12:56:22 +03:00

225 lines
11 KiB
Bash
Raw Permalink 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 перебивает наш; НЕ перезапускаю"
cat <<EOF
============================================================
Настройка завершена. sshd ещё НЕ перезапущен намеренно.
Прежде чем перезапускать, в ОТДЕЛЬНОМ окне проверь вход по ключу:
ssh -p $SSH_PORT $ADMIN_USER@\$(hostname -I | awk '{print \$1}')
Только когда вход по ключу работает:
systemctl reload ssh
Если перезапустить раньше и ключ не подойдёт, парольный вход уже
выключён — останется только IP-KVM за 1320 руб.
============================================================
EOF