365 lines
15 KiB
Bash
Executable File
365 lines
15 KiB
Bash
Executable File
#!/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 "Готово."
|