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
Найдено при первом живом запуске на 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).
225 lines
11 KiB
Bash
225 lines
11 KiB
Bash
#!/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
|