Files
backup-master/internal/restore/provision.sh
T
mrleo1nidandClaude Opus 5.5 1d44d067e9 backup-master: сервер бэкапов, агент и восстановление VPS
Один бинарник backupctl:
- server: API для агентов и restore, веб-морда (один админ), SQLite,
  ретеншн GFS, heavy-части, уведомления ntfy, раздача agent.sh/restore.sh;
- backup/plan: сбор по каталогу категорий (files/systemd/xui/compose),
  горячие дампы Postgres/Mongo/MySQL, снимок x-ui.db, docker-тома,
  digest образов, проверка crontab, потоковая отправка tar.zst;
- restore: провижининг на основе vps_installer с теми же версиями пакетов,
  recipes 3x-ui/nebula/sing-box нужных версий, восстановление compose
  с заливкой дампов и закреплением образов по digest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:52:40 +03:00

338 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"
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
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 "Базовая подготовка завершена"