mbs-panel/mbs
savsis 264991593a feat: mbs update takes a mirror url, backs up first and survives manual edits
Manual edits on the server are stashed (and saved as a patch) instead of aborting the update,
a full backup is taken before every update, mbs mirror remembers a custom source, unsafe urls
are refused, rollback restores the stashed edits. Covered by tests/test_mbs_update.sh.
2026-10-04 03:51:16 +05:00

292 lines
11 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.

#!/bin/bash
set -e
APP_DIR="/opt/mbs-panel"
ENV_FILE="$APP_DIR/.env"
usage() {
cat << 'EOF'
mbs — управление MBS Panel
mbs pass [новый_пароль] сменить пароль админ-панели (без аргумента — сгенерировать случайный)
mbs status статус всех сервисов (bot, api, xray, nginx)
mbs restart перезапустить всё (bot, api, xray, reload nginx)
mbs logs [bot|api|xray] последние строки лога (по умолчанию api)
mbs domain показать текущий домен панели
mbs backup полная копия панели (база, .env, ручные правки, конфиг Xray) в /root/mbs-backups
mbs update [ссылка] обновить код и перезапустить (не трогает .env и базу, перед этим сам делает копию). Без ссылки: своё зеркало -> api.savsis.xyz -> GitHub.
Со ссылкой на git-репозиторий (зеркало) — берёт обновление оттуда, один раз
mbs mirror [ссылка|off] показать / запомнить / убрать своё зеркало, которое mbs update проверяет первым
EOF
}
cmd_pass() {
local new_pass="$1"
if [ -z "$new_pass" ]; then
new_pass=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 14)
fi
if grep -q "^ADMIN_PANEL_PASSWORD=" "$ENV_FILE"; then
sed -i "s#^ADMIN_PANEL_PASSWORD=.*#ADMIN_PANEL_PASSWORD=$new_pass#" "$ENV_FILE"
else
echo "ADMIN_PANEL_PASSWORD=$new_pass" >> "$ENV_FILE"
fi
systemctl restart mbs-api
echo "Новый пароль панели: $new_pass"
}
cmd_status() {
systemctl --no-pager status mbs-bot mbs-api xray nginx 2>&1 | grep -E "●|Active:"
}
cmd_restart() {
systemctl restart mbs-bot mbs-api xray
systemctl reload nginx 2>/dev/null || true
echo "Перезапущено: bot, api, xray (+ reload nginx)."
}
cmd_logs() {
local svc="${1:-api}"
case "$svc" in
bot) journalctl -u mbs-bot -n 60 --no-pager ;;
api) journalctl -u mbs-api -n 60 --no-pager ;;
xray) journalctl -u xray -n 60 --no-pager ;;
*) echo "неизвестный сервис: $svc (bot|api|xray)"; exit 1 ;;
esac
}
cmd_domain() {
grep "^PANEL_DOMAIN=" "$ENV_FILE"
}
MIRROR_FILE="$APP_DIR/.update_mirror"
BACKUP_DIR="${MBS_BACKUP_DIR:-/root/mbs-backups}"
XRAY_CONFIG="/usr/local/etc/xray/config.json"
BACKUP_FILE=""
UPDATE_STASHED=0
snapshot_backup() {
local stamp dbsnap file
local -a targs=(--exclude=venv --exclude=__pycache__)
stamp=$(date +%Y%m%d-%H%M%S)
file="$BACKUP_DIR/mbs-$1-$stamp.tar.gz"
dbsnap="$APP_DIR/.mbs.db.snapshot"
mkdir -p "$BACKUP_DIR" || return 1
chmod 700 "$BACKUP_DIR"
rm -f "$dbsnap"
if [ -f "$APP_DIR/mbs.db" ] && [ -x "$APP_DIR/venv/bin/python" ] && \
"$APP_DIR/venv/bin/python" -c "import sqlite3,sys; s=sqlite3.connect(sys.argv[1]); d=sqlite3.connect(sys.argv[2]); s.backup(d); d.close(); s.close()" "$APP_DIR/mbs.db" "$dbsnap" 2>/dev/null; then
targs+=(--exclude=mbs.db --exclude=mbs.db-wal --exclude=mbs.db-shm "--transform=s#\.mbs\.db\.snapshot#mbs.db#")
fi
if [ -f "$XRAY_CONFIG" ]; then
tar czf "$file" "${targs[@]}" "$APP_DIR" "$XRAY_CONFIG" 2>/dev/null
else
tar czf "$file" "${targs[@]}" "$APP_DIR" 2>/dev/null
fi
local rc=$?
rm -f "$dbsnap"
if [ $rc -ne 0 ] || [ ! -s "$file" ]; then
rm -f "$file"
return 1
fi
chmod 600 "$file"
ls -1t "$BACKUP_DIR"/mbs-*.tar.gz 2>/dev/null | tail -n +6 | while read -r old; do rm -f "$old"; done
BACKUP_FILE="$file"
return 0
}
cmd_backup() {
if snapshot_backup manual; then
echo "копия сохранена: $BACKUP_FILE"
echo "внутри: база (консистентный снапшот), .env, код с твоими правками, конфиг Xray. Хранится 5 последних."
else
echo "не удалось сделать копию в $BACKUP_DIR (место на диске?)"
return 1
fi
}
valid_url() {
case "$1" in
https://*|http://*|ssh://*|git@*:*) ;;
*) return 1 ;;
esac
case "$1" in
*[[:space:]]*) return 1 ;;
esac
return 0
}
fetch_url() {
git -c protocol.ext.allow=never fetch --quiet -- "$1" main 2>/dev/null
}
restore_stash() {
if [ "$UPDATE_STASHED" = "1" ]; then
UPDATE_STASHED=0
if git stash pop --quiet 2>/dev/null; then
echo "ручные правки вернул на место"
else
echo "ручные правки остались в git stash (git stash list)"
fi
fi
}
cmd_mirror() {
local url="$1"
case "$url" in
"")
if [ -f "$MIRROR_FILE" ]; then
echo "своё зеркало для обновлений: $(head -n 1 "$MIRROR_FILE")"
else
echo "своё зеркало не задано — mbs update берёт api.savsis.xyz, потом GitHub"
fi
;;
off|clear)
rm -f "$MIRROR_FILE"
echo "своё зеркало убрано"
;;
*)
if ! valid_url "$url"; then
echo "это не похоже на ссылку на git-репозиторий (нужна https://..., ssh://... или git@хост:путь)"
return 1
fi
printf '%s\n' "$url" > "$MIRROR_FILE"
echo "зеркало запомнил: $url — mbs update теперь проверяет его первым"
;;
esac
}
cmd_update() {
local arg_url="$1" target="" saved="" before="" stamp="" patch=""
cd "$APP_DIR"
echo "проверяю обновления..."
if [ -n "$arg_url" ]; then
if ! valid_url "$arg_url"; then
echo "это не похоже на ссылку на git-репозиторий (нужна https://..., ssh://... или git@хост:путь)"
return 1
fi
if ! fetch_url "$arg_url"; then
echo "не удалось получить обновления по ссылке: $arg_url"
return 1
fi
target=$(git rev-parse FETCH_HEAD)
echo "источник: $arg_url"
else
if [ -f "$MIRROR_FILE" ]; then
saved=$(head -n 1 "$MIRROR_FILE" | tr -d '[:space:]')
fi
if [ -n "$saved" ] && valid_url "$saved" && fetch_url "$saved"; then
target=$(git rev-parse FETCH_HEAD)
echo "источник: своё зеркало $saved"
elif git fetch --quiet origin main 2>/dev/null; then
target=$(git rev-parse origin/main)
elif git remote | grep -q '^github$' && git fetch --quiet github main 2>/dev/null; then
echo "зеркало недоступно, взял с github..."
target=$(git rev-parse github/main)
else
echo "не удалось получить обновления ни с зеркала, ни с github"
return 1
fi
fi
before=$(git rev-parse HEAD)
if [ "$before" = "$target" ]; then
echo "уже последняя версия ($before)."
return 0
fi
if git merge-base --is-ancestor "$target" "$before" 2>/dev/null; then
echo "на сервере версия новее, чем в источнике ($before) — ничего не делаю."
return 0
fi
echo "текущая: $before"
echo "новая: $target"
if ! snapshot_backup before-update; then
echo "не вышло сделать резервную копию в $BACKUP_DIR — обновление не начинаю, чтобы ничего не потерять (место на диске?)"
return 1
fi
echo "резервная копия перед обновлением: $BACKUP_FILE"
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
stamp=$(date +%Y%m%d-%H%M%S)
mkdir -p "$APP_DIR/local-changes"
patch="$APP_DIR/local-changes/local-changes-$stamp.patch"
git diff HEAD > "$patch"
if GIT_AUTHOR_NAME=mbs GIT_AUTHOR_EMAIL=mbs@localhost GIT_COMMITTER_NAME=mbs GIT_COMMITTER_EMAIL=mbs@localhost git stash push --quiet -m "mbs-update-$stamp"; then
UPDATE_STASHED=1
echo "на сервере были ручные правки — убрал в сторону, ничего не потеряно:"
echo " патч: $patch"
echo " stash: git stash list (вернуть обратно: git stash pop)"
else
echo "не вышло спрятать ручные правки, остановился — разберись руками: git status"
return 1
fi
fi
if ! git merge --ff-only "$target" --quiet 2>/dev/null; then
echo "не вышло быстро обновиться (на сервере есть свои коммиты, которых нет в источнике) — разберись руками: git log"
restore_stash
return 1
fi
echo "обновляю зависимости..."
venv/bin/pip install --quiet -r requirements.txt
echo "проверяю код..."
if ! venv/bin/python -m py_compile *.py; then
echo "новый код не проходит проверку, откатываюсь на $before..."
git reset --hard "$before" --quiet
venv/bin/pip install --quiet -r requirements.txt
restore_stash
return 1
fi
echo "обновляю сам CLI..."
cp "$APP_DIR/mbs" /usr/local/bin/mbs
chmod +x /usr/local/bin/mbs
echo "обновляю systemd-юниты..."
local cpu_count api_workers
cpu_count=$(nproc 2>/dev/null || echo 1)
if [ "$cpu_count" -lt 2 ]; then api_workers=1
elif [ "$cpu_count" -gt 4 ]; then api_workers=4
else api_workers=$cpu_count
fi
cp "$APP_DIR/systemd/mbs-bot.service" /etc/systemd/system/mbs-bot.service
sed "s/__WORKERS__/$api_workers/" "$APP_DIR/systemd/mbs-api.service" > /etc/systemd/system/mbs-api.service
systemctl daemon-reload
echo "перезапускаю..."
systemctl restart mbs-bot mbs-api xray
systemctl reload nginx 2>/dev/null || true
sleep 2
if systemctl is-active --quiet mbs-bot && systemctl is-active --quiet mbs-api; then
echo "обновлено: $before -> $(git rev-parse --short HEAD)"
if [ "$UPDATE_STASHED" = "1" ]; then
echo "твои ручные правки лежат в git stash и в $patch — если они нужны, посмотри git stash show -p"
fi
echo "если что-то пошло не так: копия $BACKUP_FILE (распаковать: tar xzf файл -C /)"
if [ -n "$arg_url" ]; then
echo "чтобы всегда обновляться с этого зеркала: mbs mirror $arg_url"
fi
else
echo "сервисы не поднялись после обновления, откатываюсь на $before..."
git reset --hard "$before" --quiet
venv/bin/pip install --quiet -r requirements.txt
systemctl restart mbs-bot mbs-api
restore_stash
echo "откачено обратно на $before"
return 1
fi
}
main() {
case "$1" in
pass) cmd_pass "$2" ;;
status) cmd_status ;;
restart) cmd_restart ;;
logs) cmd_logs "$2" ;;
domain) cmd_domain ;;
update) cmd_update "$2" ;;
mirror) cmd_mirror "$2" ;;
backup) cmd_backup ;;
*) usage ;;
esac
}
main "$@"; exit $?