chore(ops): скрипты аудита и первичной настройки выделенного сервера Selectel #3050

Merged
lekss361 merged 2 commits from chore/selectel-bootstrap into main 2026-08-22 11:53:34 +00:00
3 changed files with 310 additions and 0 deletions

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
*.sh text eol=lf

84
ops/selectel-audit.sh Normal file
View file

@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Аудит нового выделенного сервера ДО того, как на него что-либо поставлено.
#
# Только чтение — ничего не меняет. Задача одна: собрать факты, по которым
# принимается решение «работаем на этой установке» или «переставляем ОС».
#
# Главный вопрос — RAID. На AR32-NVMe два диска по 1 ТБ. Если они отданы
# по отдельности или собраны в RAID0, смерть одного диска = потеря всего,
# а память на этой платформе non-ECC. Переставить ОС можно только пока
# сервер пустой, поэтому проверка идёт первой и до всего остального.
#
# Запуск: ssh <host> 'bash -s' < ops/selectel-audit.sh
set -uo pipefail
section() { printf '\n=== %s ===\n' "$1"; }
section "Идентификация"
. /etc/os-release 2>/dev/null && echo "OS: ${PRETTY_NAME:-unknown}"
echo "Kernel: $(uname -r)"
echo "Hostname: $(hostname)"
echo "Uptime: $(uptime -p 2>/dev/null || true)"
section "CPU / память"
lscpu 2>/dev/null | grep -E '^(Model name|CPU\(s\)|Thread|Core|Socket|CPU max MHz)' || nproc
free -h | head -2
echo "--- ECC (пусто = не-ECC либо недоступно из ОС) ---"
dmidecode -t memory 2>/dev/null | grep -iE 'error correction|^\s+Type:' | sort -u | head -5 \
|| echo "dmidecode недоступен (нужен root)"
section "ДИСКИ — ключевой вопрос"
lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL
echo "--- программный RAID ---"
if [ -f /proc/mdstat ]; then cat /proc/mdstat; else echo "/proc/mdstat отсутствует"; fi
echo "--- аппаратный RAID / NVMe ---"
lspci 2>/dev/null | grep -iE 'raid|nvme' || echo "контроллеров RAID не видно"
nvme list 2>/dev/null || echo "nvme-cli не установлен"
echo "--- LVM ---"
pvs 2>/dev/null; vgs 2>/dev/null; lvs 2>/dev/null
echo "--- монтирование ---"
df -hT -x tmpfs -x devtmpfs
echo
echo ">>> ВЕРДИКТ ПО ДИСКАМ читать так:"
echo ">>> raid1 / mirror в mdstat → зеркало есть, можно работать"
echo ">>> raid0 / linear / дисков нет → зеркала НЕТ, переставить ОС до установки чего-либо"
echo ">>> один диск в df, второй пуст → второй не используется, решить как задействовать"
section "Сеть"
ip -brief addr show | grep -v '^lo'
ip route | head -5
echo "--- DNS ---"
grep -E '^nameserver' /etc/resolv.conf 2>/dev/null | head -3
section "Что уже слушает"
ss -tulpn 2>/dev/null | grep LISTEN || netstat -tulpn 2>/dev/null | grep LISTEN
section "Безопасность — текущее состояние"
echo "--- sshd (значимые директивы) ---"
sshd -T 2>/dev/null | grep -E '^(permitrootlogin|passwordauthentication|pubkeyauthentication|port|permitemptypasswords)' \
|| grep -E '^\s*(PermitRootLogin|PasswordAuthentication|Port)' /etc/ssh/sshd_config /etc/ssh/sshd_config.d/* 2>/dev/null
echo "--- файрвол ---"
ufw status 2>/dev/null || echo "ufw не установлен"
nft list ruleset 2>/dev/null | head -20 || iptables -S 2>/dev/null | head -20
echo "--- пользователи с shell ---"
awk -F: '$7 !~ /(nologin|false)$/ {print $1" "$7}' /etc/passwd
section "Swap / sysctl"
swapon --show || echo "swap отсутствует"
sysctl vm.swappiness vm.overcommit_memory 2>/dev/null
section "Уже установленное ПО"
for b in docker containerd git curl ufw fail2ban-server unattended-upgrade; do
if command -v "$b" >/dev/null 2>&1; then
printf '%-22s %s\n' "$b" "$($b --version 2>/dev/null | head -1)"
else
printf '%-22s %s\n' "$b" "НЕ установлен"
fi
done
section "Время"
timedatectl 2>/dev/null | head -5 || date
printf '\n=== Аудит закончен ===\n'

225
ops/selectel-bootstrap.sh Normal file
View file

@ -0,0 +1,225 @@
#!/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