From 6484116079dec47697bc37204281046b676b1c70 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 22 Aug 2026 12:49:09 +0300 Subject: [PATCH 1/2] =?UTF-8?q?chore(ops):=20=D1=81=D0=BA=D1=80=D0=B8?= =?UTF-8?q?=D0=BF=D1=82=D1=8B=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B2=D0=B8=D1=87=D0=BD=D0=BE=D0=B9?= =?UTF-8?q?=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0=20Selectel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сервер Poincare (SPB-4, 188.246.224.93) поднят 22.08.2026. Два скрипта под переезд (#2989, #3027-#3032). selectel-audit.sh — только чтение. Отвечает на вопрос, который надо закрыть до установки чего бы то ни было: собраны ли диски в зеркало. Переставить ОС можно только пока сервер пустой. Результат первого прогона: RAID1 на всех трёх разделах ([2/2] [UU] на /boot, swap и /), память non-ECC подтверждена (Error Correction Type: None), 910G свободно из 924G. selectel-bootstrap.sh — идемпотентная настройка: пользователь с ключом, ufw, Docker версий текущего прода (29.4.1 / v5.1.3), лимит журналов контейнеров, sysctl, unattended-upgrades, fail2ban, ужесточение sshd. Порядок шагов выбран так, чтобы не потерять доступ: правило для SSH добавляется ДО включения файрвола, а sshd в конце намеренно НЕ перезапускается — сначала проверяется вход по ключу в отдельном окне. При выключенном PasswordAuthentication ошибка означала бы IP-KVM за 1320 руб. Два умолчания выставлены по факту, а не по привычке: - часовой пояс Europe/Moscow, а не UTC — оба хоста сейчас на MSK, и смена пояса сдвинула бы все шесть cron-задач (бэкапы, геокодинг) на три часа; - swap не создаётся, если он уже есть — установщик Selectel отдаёт раздел 4.7G в зеркале, добавлять файл поверх незачем при 62G памяти. .gitattributes: *.sh с LF — иначе скрипт, отредактированный под Windows, падает на удалённом хосте с `$'\r': command not found`. --- .gitattributes | 1 + ops/selectel-audit.sh | 84 +++++++++++++++ ops/selectel-bootstrap.sh | 209 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 294 insertions(+) create mode 100644 .gitattributes create mode 100644 ops/selectel-audit.sh create mode 100644 ops/selectel-bootstrap.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..dfdb8b77 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/ops/selectel-audit.sh b/ops/selectel-audit.sh new file mode 100644 index 00000000..da5bd651 --- /dev/null +++ b/ops/selectel-audit.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Аудит нового выделенного сервера ДО того, как на него что-либо поставлено. +# +# Только чтение — ничего не меняет. Задача одна: собрать факты, по которым +# принимается решение «работаем на этой установке» или «переставляем ОС». +# +# Главный вопрос — RAID. На AR32-NVMe два диска по 1 ТБ. Если они отданы +# по отдельности или собраны в RAID0, смерть одного диска = потеря всего, +# а память на этой платформе non-ECC. Переставить ОС можно только пока +# сервер пустой, поэтому проверка идёт первой и до всего остального. +# +# Запуск: ssh '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' diff --git a/ops/selectel-bootstrap.sh b/ops/selectel-bootstrap.sh new file mode 100644 index 00000000..3c9bf465 --- /dev/null +++ b/ops/selectel-bootstrap.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +# Первичная настройка нового выделенного сервера (Selectel AR32-NVMe, Ubuntu 24.04). +# +# Идемпотентен: повторный запуск ничего не ломает и не дублирует. +# Ничего не удаляет и не переразмечает диски. +# +# ВАЖНО: сначала прогнать ops/selectel-audit.sh и убедиться, что диски в зеркале. +# Если зеркала нет — ОС переставляется ДО этого скрипта, иначе всю настройку +# придётся выкидывать вместе с переустановкой. +# +# Порядок внутри выбран так, чтобы не потерять доступ: правило для SSH в файрвол +# добавляется ДО его включения, парольный вход выключается ПОСЛЕ проверки, что +# ключ работает. +# +# Запуск (от root): ssh root@ '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}" + +# Версии — те же, что на текущем прод-хосте, чтобы поведение compose не разъехалось. +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 </dev/null +systemctl is-active fail2ban + +log "10. Ужесточение sshd" +cat > /etc/ssh/sshd_config.d/60-hardening.conf < Date: Sat, 22 Aug 2026 12:56:22 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(ops):=20hardening=20sshd=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=BB=D1=87=D0=B0=20=D0=BD=D0=B5=20=D0=BF=D1=80=D0=B8=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=8F=D0=BB=D1=81=D1=8F=20=D0=B8=D0=B7-=D0=B7?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=B8=D0=BE=D1=80=D0=B8=D1=82=D0=B5=D1=82?= =?UTF-8?q?=D0=B0=20cloud-init?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Найдено при первом живом запуске на 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). --- ops/selectel-bootstrap.sh | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/ops/selectel-bootstrap.sh b/ops/selectel-bootstrap.sh index 3c9bf465..951a274b 100644 --- a/ops/selectel-bootstrap.sh +++ b/ops/selectel-bootstrap.sh @@ -28,7 +28,11 @@ ADMIN_PUBKEY="${ADMIN_PUBKEY:-}" SSH_PORT="${SSH_PORT:-22}" SWAP_GB="${SWAP_GB:-8}" -# Версии — те же, что на текущем прод-хосте, чтобы поведение compose не разъехалось. +# Версии на текущем прод-хосте — ориентир для сверки, НЕ то, что ставится. +# Репозиторий 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" @@ -177,7 +181,12 @@ systemctl enable --now fail2ban >/dev/null systemctl is-active fail2ban log "10. Ужесточение sshd" -cat > /etc/ssh/sshd_config.d/60-hardening.conf < /etc/ssh/sshd_config.d/00-hardening.conf <