Files
vps_installer/setup.sh
T
2026-10-06 11:15:55 +03:00

365 lines
15 KiB
Bash
Executable File
Raw 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
#
# Первоначальная настройка сервера 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 <url> | 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 <<EOF
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
# свои IP, чтобы не забанить себя
ignoreip = $IGNORE_IP
[sshd]
enabled = true
backend = systemd
EOF
systemctl enable fail2ban >/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 <<EOF
$DOMAIN {
reverse_proxy $UPSTREAM
}
EOF
if caddy validate --config /etc/caddy/Caddyfile --adapter 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 "Готово."