Files
backup-master/internal/restore/provision.sh
T
mrleo1nidandClaude Opus 5.5 cbd3cde01d
CI / test (push) Successful in 1m48s
Бэкап fail2ban и своих geo-файлов 3x-ui
- новая категория fail2ban: jail.local, jail.d (в т.ч. 3x-ipl.local),
  IP-лимит 3x-ui (filter.d/action.d/3x-ipl.conf), свои *.local;
  при восстановлении создаются логи 3xipl*.log, без которых jail не поднимается
- xui: свои *.dat из /usr/local/x-ui/bin (ipcheck, discord-*) — на них ссылается
  роутинг; штатные geo-файлы релиза не бэкапятся
- recipe 3x-ui больше не очищает /usr/local/x-ui и не стирает восстановленные файлы
- маски (*, ?) в paths и keep_binaries каталога
- provision ставит nftables вместе с fail2ban
- .gitattributes: LF во всех файлах

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:24:21 +03:00

339 lines
14 KiB
Bash
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
#
# Базовая подготовка нового сервера перед восстановлением из backup-master.
# Основано на vps_installer/setup.sh (https://gitea.hsrv.site/mrleo1nid/vps_installer),
# но рассчитано на запуск из `backupctl restore`:
# - все параметры приходят через окружение (их выставляет restore по манифесту);
# - интерактивны только NEW_USER (по умолчанию DEFAULT_USER) и NEW_PASS;
# - apt-пакеты ставятся точных версий со старого сервера, если они есть в репозитории;
# - 3x-ui, Nebula, sing-box ставит restore (нужной версии), здесь их нет;
# - без перезагрузки — о ней напомнит restore в конце.
#
# Окружение:
# NEW_USER, NEW_PASS, DEFAULT_USER, NEW_HOSTNAME,
# INSTALL_BLOCKLIST, BLOCKLIST_URL, HARDEN_SSH (y/n),
# INSTALL_CADDY, CADDY_VERSION, INSTALL_TELEGRAF, TELEGRAF_VERSION,
# INSTALL_DOCKER, DOCKER_CE_VERSION, DOCKER_CLI_VERSION, COMPOSE_VERSION
set -Eeuo pipefail
REPO_RAW="https://gitea.hsrv.site/mrleo1nid/vps_installer/raw/branch/main"
STATUSPAGE_DIR="/var/www/statuspage"
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
: "${INSTALL_BLOCKLIST:=y}" "${HARDEN_SSH:=y}" "${INSTALL_CADDY:=n}" "${INSTALL_TELEGRAF:=n}" "${INSTALL_DOCKER:=n}"
: "${BLOCKLIST_URL:=$DEFAULT_BLOCKLIST_URL}"
# ---------- вывод ----------
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) ----------
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_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 "$@"
}
# pkg_spec PKG VERSION — "pkg=version", если такая версия есть в репозитории, иначе "pkg"
pkg_spec() {
local pkg=$1 ver=${2:-}
if [[ -n $ver ]] && apt-cache madison "$pkg" 2>/dev/null | awk -F'|' '{gsub(/ /,"",$2); print $2}' | grep -qxF "$ver"; then
echo "$pkg=$ver"
else
[[ -n $ver ]] && warn "$pkg $ver нет в репозитории — будет установлена последняя версия" >&2
echo "$pkg"
fi
}
# ---------- проверки ----------
[[ $EUID -eq 0 ]] || die "Нужен root"
. /etc/os-release
[[ ${ID:-} == ubuntu ]] || die "Скрипт рассчитан на Ubuntu (обнаружено: ${PRETTY_NAME:-?})"
[[ ${VERSION_ID:-} == 24.04 ]] || warn "Скрипт писался под Ubuntu 24.04, у вас ${PRETTY_NAME}. Продолжаю."
ARCH=$(dpkg --print-architecture)
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 "Логин пользователя с sudo" "${DEFAULT_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
info "Пользователь $NEW_USER уже есть — пароль не меняю (задайте NEW_PASS, чтобы сменить)"
else
ask_password NEW_PASS
fi
[[ -n ${NEW_HOSTNAME:-} ]] || ask NEW_HOSTNAME "Имя машины (hostname)" "$(hostname)"
NEW_HOSTNAME=${NEW_HOSTNAME,,}
touch "$LOG_FILE" && chmod 600 "$LOG_FILE"
exec > >(tee -a "$LOG_FILE") 2>&1
echo "===== $(date '+%F %T') backup-master provision 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
if [[ -n ${NEW_PASS:-} ]]; then
echo "$NEW_USER:$NEW_PASS" | chpasswd
unset NEW_PASS
fi
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
# ---------- 3. Имя машины ----------
step "3. Имя машины: $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
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)"
# ---------- 4. iptables legacy ----------
step "4. Переключение на 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)"
# ---------- 5. Fail2ban ----------
step "5. Fail2ban"
# nftables: штатный banaction fail2ban в Ubuntu 24.04 — nftables, без nft jail sshd не банит
apt_install fail2ban python3-systemd nftables
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
ok "Fail2ban включён"
# ---------- 6. UFW ----------
# правила со старого сервера восстановит категория system (ufw reload после распаковки)
step "6. Firewall (UFW)"
apt_install ufw
ufw default deny incoming >/dev/null
ufw default allow outgoing >/dev/null
ufw allow OpenSSH >/dev/null
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
fi
ufw allow 80/tcp >/dev/null
ufw allow 443/tcp >/dev/null
ufw --force enable >/dev/null
ok "UFW включён: SSH, 80, 443"
# ---------- 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 настроен"
else
step "7. Блокировка IP — пропущено"
fi
# ---------- 8. Статус-страница ----------
step "8. Статус-страница $STATUSPAGE_DIR"
if [[ -f $STATUSPAGE_DIR/index.html ]]; then
info "уже есть — не трогаю"
else
install -d -m 755 "$STATUSPAGE_DIR"
curl -fsSL "$REPO_RAW/statuspage/index.html" -o "$STATUSPAGE_DIR/index.html" || warn "статус-страница не скачалась"
chmod 644 "$STATUSPAGE_DIR/index.html" 2>/dev/null || true
fi
# ---------- 9. Docker ----------
if [[ $INSTALL_DOCKER == y ]]; then
step "9. Docker + Docker Compose"
for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do
dpkg -s "$pkg" &>/dev/null && apt-get remove -y -q "$pkg"
done
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$ARCH signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $VERSION_CODENAME stable" \
>/etc/apt/sources.list.d/docker.list
apt-get update -q
apt_install --allow-downgrades \
"$(pkg_spec docker-ce "${DOCKER_CE_VERSION:-}")" \
"$(pkg_spec docker-ce-cli "${DOCKER_CLI_VERSION:-}")" \
containerd.io docker-buildx-plugin \
"$(pkg_spec docker-compose-plugin "${COMPOSE_VERSION:-}")"
systemctl enable --now docker.service containerd.service >/dev/null 2>&1
usermod -aG docker "$NEW_USER"
ok "$(docker --version), $(docker compose version | head -n1)"
else
step "9. Docker — пропущено"
fi
# ---------- 10. Telegraf ----------
if [[ $INSTALL_TELEGRAF == y ]]; then
step "10. Telegraf"
mkdir -p /etc/apt/keyrings
curl -fsSL https://repos.influxdata.com/influxdata-archive.key \
| gpg --batch --yes --dearmor -o /etc/apt/keyrings/influxdata.gpg
echo "deb [signed-by=/etc/apt/keyrings/influxdata.gpg] https://repos.influxdata.com/ubuntu stable main" \
>/etc/apt/sources.list.d/influxdata.list
apt-get update -q
apt_install --allow-downgrades "$(pkg_spec telegraf "${TELEGRAF_VERSION:-}")" influxdata-archive-keyring
# запуск — после восстановления конфига, по состоянию со старого сервера
systemctl disable --now telegraf >/dev/null 2>&1 || true
ok "$(telegraf --version) установлен"
else
step "10. Telegraf — пропущено"
fi
# ---------- 11. Caddy ----------
if [[ $INSTALL_CADDY == y ]]; then
step "11. 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 --allow-downgrades "$(pkg_spec caddy "${CADDY_VERSION:-}")"
ok "Caddy $(caddy version | awk '{print $1}') установлен"
else
step "11. Caddy — пропущено"
fi
# ---------- 12. Отключение доступа root ----------
if [[ $HARDEN_SSH == y ]]; then
step "12. Отключение доступа root"
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 "12. Отключение доступа root — пропущено"
fi
ok "Базовая подготовка завершена"