commit 52c01f225bb6bfc0525f8350f74e4135f85d5d47 Author: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Tue Oct 6 11:15:55 2026 +0300 Add initial Ubuntu 24.04 server setup script Co-Authored-By: Claude Opus 5.5 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/README.md b/README.md new file mode 100644 index 0000000..ede7146 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# vps_installer + +Первоначальная настройка сервера Ubuntu 24.04 по [инструкции](https://wiki.hsrv.site/books/pervonacalnaia-nastroika-ubuntu/page/pervonacalnaia-nastroika-servera-ubuntu-2404). + +## Запуск + +Под root на свежем сервере: + +```bash +curl -fsSL https://gitea.hsrv.site/mrleo1nid/vps_installer/raw/branch/main/setup.sh | sudo bash +``` + +Скрипт спросит логин и пароль нового пользователя, имя машины, домен для Caddy и т.д. + +## Что делает + +1. `apt upgrade`, включает unattended-upgrades +2. Создаёт пользователя с sudo без пароля (вход по SSH — по паролю) +3. Меняет hostname (+ `/etc/hosts`, cloud-init не перезапишет его при перезагрузке) +4. Переключает iptables на legacy +5. Ставит fail2ban (jail sshd, ваш текущий IP в ignoreip) +6. UFW: открыты SSH, 80, 443 +7. Caddy (опционально — с reverse_proxy на домен) +8. Блокировка IP по внешнему списку через ipset + cron (`/etc/cron.d/ban-blacklist`) +9. Запрещает вход root по SSH и блокирует пароль root +10. Предлагает перезагрузку + +Лог: `/var/log/vps-setup.log`. + +## Без вопросов + +Любой ответ можно передать переменной окружения: + +```bash +curl -fsSL https://gitea.hsrv.site/mrleo1nid/vps_installer/raw/branch/main/setup.sh | sudo NEW_USER=admin NEW_HOSTNAME=vps1.example.com INSTALL_CADDY=y DOMAIN=example.com UPSTREAM=127.0.0.1:8080 INSTALL_BLOCKLIST=y HARDEN_SSH=y bash +``` + +Переменные: `NEW_USER`, `NEW_PASS`, `NEW_HOSTNAME`, `INSTALL_CADDY`, `DOMAIN`, `UPSTREAM`, +`INSTALL_BLOCKLIST`, `BLOCKLIST_URL`, `HARDEN_SSH`, `DO_REBOOT`. diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..7715127 --- /dev/null +++ b/setup.sh @@ -0,0 +1,364 @@ +#!/usr/bin/env bash +# +# Первоначальная настройка сервера Ubuntu 24.04 +# по инструкции: https://wiki.hsrv.site/books/pervonacalnaia-nastroika-ubuntu/page/pervonacalnaia-nastroika-servera-ubuntu-2404 +# +# Запуск (от root): +# curl -fsSL https://gitea.hsrv.site/mrleo1nid/vps_installer/raw/branch/main/setup.sh | sudo bash +# +# Все вопросы задаются через /dev/tty, поэтому запуск через пайп работает. +# Ответы можно передать заранее через переменные окружения (тогда вопрос не задаётся): +# NEW_USER, NEW_PASS, NEW_HOSTNAME, INSTALL_CADDY (y/n), DOMAIN, UPSTREAM, +# INSTALL_BLOCKLIST (y/n), BLOCKLIST_URL, HARDEN_SSH (y/n) + +set -Eeuo pipefail + +LOG_FILE="/var/log/vps-setup.log" +DEFAULT_BLOCKLIST_URL="https://raw.githubusercontent.com/jahlib/somebody-knocked-my-ssh/refs/heads/main/ban.txt" +BAN_SCRIPT_URL="https://raw.githubusercontent.com/jahlib/somebody-knocked-my-ssh/refs/heads/main/ban.sh" +# 00- — чтобы файл читался раньше 50-cloud-init.conf: sshd берёт ПЕРВОЕ найденное значение +SSHD_HARDENING="/etc/ssh/sshd_config.d/00-hardening.conf" + +export DEBIAN_FRONTEND=noninteractive +export NEEDRESTART_MODE=a + +# ---------- вывод ---------- +if [[ -t 1 ]]; then + C_RED=$'\e[31m' C_GRN=$'\e[32m' C_YLW=$'\e[33m' C_BLU=$'\e[1;34m' C_RST=$'\e[0m' +else + C_RED='' C_GRN='' C_YLW='' C_BLU='' C_RST='' +fi +step() { echo; echo "${C_BLU}==> $*${C_RST}"; } +info() { echo " $*"; } +ok() { echo "${C_GRN} ✓ $*${C_RST}"; } +warn() { echo "${C_YLW} ! $*${C_RST}"; } +die() { echo "${C_RED}ОШИБКА: $*${C_RST}" >&2; exit 1; } + +trap 'echo "${C_RED}Скрипт упал на строке $LINENO: $BASH_COMMAND${C_RST}" >&2' ERR + +# ---------- ввод (через /dev/tty, т.к. stdin занят пайпом curl) ---------- +TTY=/dev/tty +have_tty() { [[ -r $TTY && -w $TTY ]] && { : <"$TTY"; } 2>/dev/null; } + +ask() { # ask VAR "вопрос" [default] + local __var=$1 __q=$2 __def=${3:-} __ans + [[ -n ${!__var:-} ]] && return 0 + have_tty || die "Нет терминала для вопроса «$__q». Задайте переменную $__var." + if [[ -n $__def ]]; then + read -r -p "$__q [$__def]: " __ans <"$TTY" + __ans=${__ans:-$__def} + else + read -r -p "$__q: " __ans <"$TTY" + fi + printf -v "$__var" '%s' "$__ans" +} + +ask_yn() { # ask_yn VAR "вопрос" y|n + local __var=$1 __q=$2 __def=$3 __ans + if [[ -z ${!__var:-} ]]; then + have_tty || die "Нет терминала для вопроса «$__q». Задайте переменную $__var." + local hint="[y/N]"; [[ $__def == y ]] && hint="[Y/n]" + read -r -p "$__q $hint: " __ans <"$TTY" + __ans=${__ans:-$__def} + else + __ans=${!__var} + fi + case ${__ans,,} in y|yes|д|да) printf -v "$__var" y ;; *) printf -v "$__var" n ;; esac +} + +ask_password() { # ask_password VAR + local __var=$1 p1 p2 + [[ -n ${!__var:-} ]] && return 0 + have_tty || die "Нет терминала для ввода пароля. Задайте переменную $__var." + while true; do + read -r -s -p "Пароль для $NEW_USER: " p1 <"$TTY"; echo >"$TTY" + read -r -s -p "Повторите пароль: " p2 <"$TTY"; echo >"$TTY" + if [[ -z $p1 ]]; then echo "Пароль не может быть пустым." >"$TTY" + elif [[ $p1 != "$p2" ]]; then echo "Пароли не совпадают, ещё раз." >"$TTY" + elif (( ${#p1} < 8 )); then echo "Слишком короткий, минимум 8 символов." >"$TTY" + else break + fi + done + printf -v "$__var" '%s' "$p1" +} + + +apt_install() { + apt-get install -y -q -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold "$@" +} + +# ---------- проверки ---------- +[[ $EUID -eq 0 ]] || die "Запустите от root: curl -fsSL | sudo bash" +. /etc/os-release +[[ ${ID:-} == ubuntu ]] || die "Скрипт рассчитан на Ubuntu (обнаружено: ${PRETTY_NAME:-?})" +[[ ${VERSION_ID:-} == 24.04 ]] || warn "Скрипт писался под Ubuntu 24.04, у вас ${PRETTY_NAME}. Продолжаю." + +# IP, с которого мы подключены (чтобы не забанить себя в fail2ban) +CLIENT_IP="" +if [[ -n ${SSH_CLIENT:-} ]]; then CLIENT_IP=${SSH_CLIENT%% *} +else CLIENT_IP=$(who am i 2>/dev/null | sed -nE 's/.*\(([0-9a-fA-F.:]+)\).*/\1/p' || true) +fi + +# ---------- сбор ответов ---------- +step "Параметры установки" + +while true; do + ask NEW_USER "Логин нового пользователя" + if [[ $NEW_USER =~ ^[a-z_][a-z0-9_-]{0,31}$ && $NEW_USER != root ]]; then break; fi + echo "Недопустимый логин (строчные латинские буквы, цифры, _ и -; не root)." + NEW_USER="" +done + +USER_EXISTS=n +if id "$NEW_USER" &>/dev/null; then + USER_EXISTS=y + CONTINUE_EXISTING="" + ask_yn CONTINUE_EXISTING "Пользователь $NEW_USER уже существует. Сменить ему пароль и продолжить?" n + [[ $CONTINUE_EXISTING == y ]] || die "Отменено." +fi +ask_password NEW_PASS + +while true; do + ask NEW_HOSTNAME "Имя машины (hostname, можно FQDN, напр. vps1.example.com)" "$(hostname)" + if [[ ${#NEW_HOSTNAME} -le 253 && $NEW_HOSTNAME =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$ ]]; then + NEW_HOSTNAME=${NEW_HOSTNAME,,} + break + fi + echo "Недопустимое имя (латиница, цифры, дефис, части через точку)." + NEW_HOSTNAME="" +done + +ask_yn INSTALL_CADDY "Установить Caddy (reverse proxy с автоматическим HTTPS)?" y +if [[ $INSTALL_CADDY == y ]]; then + ask DOMAIN "Домен для Caddy (пусто — настроить позже)" "-" + [[ $DOMAIN == "-" ]] && DOMAIN="" + if [[ -n $DOMAIN ]]; then + ask UPSTREAM "Куда проксировать" "127.0.0.1:8080" + fi +fi + +ask_yn INSTALL_BLOCKLIST "Блокировать IP по внешнему списку (ipset + cron)?" y +if [[ $INSTALL_BLOCKLIST == y ]]; then + ask BLOCKLIST_URL "URL списка IP" "$DEFAULT_BLOCKLIST_URL" +fi + +ask_yn HARDEN_SSH "В конце запретить вход root по SSH и заблокировать пароль root?" y + +# Всё дальнейшее пишем ещё и в лог +exec > >(tee -a "$LOG_FILE") 2>&1 +echo "===== $(date '+%F %T') vps-setup start =====" >>"$LOG_FILE" + +# ---------- 1. Обновление системы ---------- +step "1. Обновление системы" +apt-get update -q +apt-get -y -q -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold upgrade +apt_install unattended-upgrades curl ca-certificates gnupg +cat >/etc/apt/apt.conf.d/20auto-upgrades <<'EOF' +APT::Periodic::Update-Package-Lists "1"; +APT::Periodic::Unattended-Upgrade "1"; +EOF +ok "Система обновлена, автообновления безопасности включены" + +# ---------- 2. Пользователь с sudo ---------- +step "2. Пользователь $NEW_USER" +if [[ $USER_EXISTS == n ]]; then + useradd -m -s /bin/bash "$NEW_USER" + ok "Пользователь создан" +fi +echo "$NEW_USER:$NEW_PASS" | chpasswd +unset NEW_PASS +usermod -aG sudo "$NEW_USER" + +SUDOERS_FILE="/etc/sudoers.d/$NEW_USER" +echo "$NEW_USER ALL=(ALL) NOPASSWD:ALL" >"$SUDOERS_FILE.tmp" +chmod 440 "$SUDOERS_FILE.tmp" +if visudo -c -f "$SUDOERS_FILE.tmp" >/dev/null; then + mv "$SUDOERS_FILE.tmp" "$SUDOERS_FILE" + ok "sudo без пароля настроен ($SUDOERS_FILE)" +else + rm -f "$SUDOERS_FILE.tmp" + die "visudo отверг файл sudoers" +fi + +# ---------- Имя машины ---------- +step "Имя машины: $NEW_HOSTNAME" +SHORT_HOSTNAME=${NEW_HOSTNAME%%.*} +hostnamectl set-hostname "$NEW_HOSTNAME" +HOSTS_LINE="127.0.1.1 $NEW_HOSTNAME" +[[ $SHORT_HOSTNAME != "$NEW_HOSTNAME" ]] && HOSTS_LINE+=" $SHORT_HOSTNAME" +if grep -q '^127\.0\.1\.1[[:space:]]' /etc/hosts; then + sed -i "s/^127\.0\.1\.1[[:space:]].*/$HOSTS_LINE/" /etc/hosts +else + echo "$HOSTS_LINE" >>/etc/hosts +fi +# иначе cloud-init вернёт старое имя при перезагрузке +if [[ -d /etc/cloud/cloud.cfg.d ]]; then + echo "preserve_hostname: true" >/etc/cloud/cloud.cfg.d/99-preserve-hostname.cfg +fi +ok "hostname: $(hostname) (в приглашении терминала — после переподключения)" + +# ---------- 3. iptables legacy ---------- +step "3. Переключение на iptables-legacy" +apt_install iptables ipset +for t in iptables ip6tables arptables ebtables; do + if [[ -x /usr/sbin/$t-legacy ]] && update-alternatives --list "$t" &>/dev/null; then + update-alternatives --set "$t" "/usr/sbin/$t-legacy" >/dev/null + fi +done +iptables --version | grep -q legacy || die "iptables не переключился на legacy" +ok "$(iptables --version)" +NEED_REBOOT=y # старые nft-правила уйдут только после перезагрузки + +# ---------- 4. Fail2ban ---------- +step "4. Fail2ban" +apt_install fail2ban python3-systemd +IGNORE_IP="127.0.0.1/8 ::1 192.168.0.0/16 10.0.0.0/8" +if [[ -n $CLIENT_IP ]]; then + IGNORE_IP+=" $CLIENT_IP" + info "Ваш текущий IP $CLIENT_IP добавлен в ignoreip" +fi +cat >/etc/fail2ban/jail.local </dev/null 2>&1 +systemctl restart fail2ban +sleep 2 +fail2ban-client status sshd >/dev/null && ok "Fail2ban работает, jail sshd активен" \ + || warn "Fail2ban не отвечает, проверьте: journalctl -u fail2ban" + +# ---------- 5. UFW ---------- +step "5. Firewall (UFW)" +apt_install ufw +ufw default deny incoming >/dev/null +ufw default allow outgoing >/dev/null +ufw allow OpenSSH >/dev/null +# если SSH висит не на 22 — открываем и его +mkdir -p /run/sshd +SSH_PORT=$(sshd -T 2>/dev/null | awk '/^port /{print $2; exit}' || true) +if [[ -n $SSH_PORT && $SSH_PORT != 22 ]]; then + ufw allow "$SSH_PORT/tcp" >/dev/null + info "Открыт нестандартный SSH-порт $SSH_PORT" +fi +ufw allow 80/tcp >/dev/null +ufw allow 443/tcp >/dev/null +ufw --force enable >/dev/null +ok "UFW включён: SSH, 80, 443" + +# ---------- 6. Caddy ---------- +if [[ $INSTALL_CADDY == y ]]; then + step "6. Caddy" + apt_install debian-keyring debian-archive-keyring apt-transport-https + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ + | gpg --batch --yes --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ + >/etc/apt/sources.list.d/caddy-stable.list + apt-get update -q + apt_install caddy + ok "Caddy $(caddy version | awk '{print $1}') установлен" + + if [[ -n ${DOMAIN:-} ]]; then + DOMAIN_IPS=$(getent ahostsv4 "$DOMAIN" | awk '{print $1}' | sort -u | tr '\n' ' ' || true) + PUBLIC_IP=$(curl -4 -fsS --max-time 5 https://api.ipify.org 2>/dev/null || true) + if [[ -z $DOMAIN_IPS ]]; then + warn "$DOMAIN не резолвится — сертификат не выпустится, пока не будет A-записи" + elif [[ -n $PUBLIC_IP && " $DOMAIN_IPS" != *" $PUBLIC_IP "* ]]; then + warn "$DOMAIN указывает на $DOMAIN_IPS, а IP сервера $PUBLIC_IP" + else + ok "$DOMAIN → $DOMAIN_IPS" + fi + + [[ -f /etc/caddy/Caddyfile ]] && cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.bak.$(date +%s)" + cat >/etc/caddy/Caddyfile </dev/null 2>&1; then + systemctl reload caddy || systemctl restart caddy + ok "Caddyfile: $DOMAIN → $UPSTREAM (логи: journalctl -u caddy -f)" + else + warn "Caddyfile не прошёл валидацию: caddy validate --config /etc/caddy/Caddyfile" + fi + else + info "Домен не задан — отредактируйте /etc/caddy/Caddyfile позже" + fi +else + step "6. Caddy — пропущено" +fi + +# ---------- 7. Блокировка IP по списку ---------- +if [[ $INSTALL_BLOCKLIST == y ]]; then + step "7. Блокировка IP по внешнему списку" + curl -fsSL "$BAN_SCRIPT_URL" -o /usr/local/sbin/ban.sh + chmod +x /usr/local/sbin/ban.sh + if [[ $BLOCKLIST_URL != "$DEFAULT_BLOCKLIST_URL" ]]; then + ESCAPED_URL=$(printf '%s' "$BLOCKLIST_URL" | sed 's/[&|\\]/\\&/g') + sed -i "s|^URL=.*|URL=\"$ESCAPED_URL\"|" /usr/local/sbin/ban.sh + fi + bash /usr/local/sbin/ban.sh || warn "ban.sh завершился с ошибкой" + cat >/etc/cron.d/ban-blacklist <<'EOF' +SHELL=/bin/bash +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +@reboot root sleep 60 && bash /usr/local/sbin/ban.sh >/dev/null 2>&1 +0 */6 * * * root bash /usr/local/sbin/ban.sh >/dev/null 2>&1 +EOF + chmod 644 /etc/cron.d/ban-blacklist + ok "ipset blacklist: $(ipset list blacklist 2>/dev/null | awk '/Number of entries/{print $4}') записей, обновление каждые 6 ч" +else + step "7. Блокировка IP — пропущено" +fi + +# ---------- 8. Отключение доступа root ---------- +if [[ $HARDEN_SSH == y ]]; then + step "8. Отключение доступа root" + # вход новым пользователем — по паролю, поэтому PasswordAuthentication оставляем включённым + cat >"$SSHD_HARDENING" <<'EOF' +PermitRootLogin no +PasswordAuthentication yes +KbdInteractiveAuthentication no +PubkeyAuthentication yes +EOF + mkdir -p /run/sshd + sshd -t || { rm -f "$SSHD_HARDENING"; die "sshd -t нашёл ошибку в конфигурации"; } + systemctl restart ssh + passwd -l root >/dev/null + ok "Вход root по SSH запрещён, пароль root заблокирован" +else + step "8. Отключение доступа root — пропущено" +fi + +# ---------- итог ---------- +step "Итоговая проверка" +info "iptables: $(iptables --version)" +info "UFW: $(ufw status | head -n1)" +info "Fail2ban: $(systemctl is-active fail2ban)" +[[ $INSTALL_CADDY == y ]] && info "Caddy: $(systemctl is-active caddy)" +[[ $INSTALL_BLOCKLIST == y ]] && info "Blacklist: $(ipset list blacklist 2>/dev/null | awk '/Number of entries/{print $4}') записей" +info "root: $(passwd -S root | awk '{print $2}') (L = заблокирован)" +info "sshd: $(sshd -T 2>/dev/null | grep -E '^(permitrootlogin|passwordauthentication) ' | tr '\n' ' ')" +info "Лог: $LOG_FILE" + +if [[ ${NEED_REBOOT:-n} == y || -f /var/run/reboot-required ]]; then + echo + warn "Нужна перезагрузка (обновление ядра / сброс старых nft-правил)." + DO_REBOOT="" + ask_yn DO_REBOOT "Перезагрузить сервер сейчас?" y + if [[ $DO_REBOOT == y ]]; then + ok "Перезагрузка через 5 секунд..." + sleep 5 + reboot + else + info "Не забудьте: sudo reboot" + fi +fi + +ok "Готово."