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>
This commit is contained in:
commit
1d44d067e9
53 files changed
+7278
No files matched your search
@@ -0,0 +1,337 @@
|
||||
#!/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 "Базовая подготовка завершена"
|
||||
Reference in new issue
Block a user