From 264991593a748dcda6a81fd83810e277bfd17925 Mon Sep 17 00:00:00 2001 From: savsis Date: Sun, 4 Oct 2026 03:51:16 +0500 Subject: [PATCH] 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. --- .gitignore | 2 + mbs | 194 +++++++++++++++++++++++++++++++++++---- tests/test_mbs_update.sh | 155 +++++++++++++++++++++++++++++++ 3 files changed, 332 insertions(+), 19 deletions(-) create mode 100644 tests/test_mbs_update.sh diff --git a/.gitignore b/.gitignore index 5516894..1119900 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ __pycache__/ *.pyc venv/ .claude/ +.update_mirror +local-changes/ diff --git a/mbs b/mbs index 63527b9..ce1f8ae 100644 --- a/mbs +++ b/mbs @@ -13,7 +13,10 @@ mbs — управление MBS Panel mbs restart перезапустить всё (bot, api, xray, reload nginx) mbs logs [bot|api|xray] последние строки лога (по умолчанию api) mbs domain показать текущий домен панели - mbs update обновить код (зеркало api.savsis.xyz, потом GitHub) и перезапустить (не трогает .env и базу) + mbs backup полная копия панели (база, .env, ручные правки, конфиг Xray) в /root/mbs-backups + mbs update [ссылка] обновить код и перезапустить (не трогает .env и базу, перед этим сам делает копию). Без ссылки: своё зеркало -> api.savsis.xyz -> GitHub. + Со ссылкой на git-репозиторий (зеркало) — берёт обновление оттуда, один раз + mbs mirror [ссылка|off] показать / запомнить / убрать своё зеркало, которое mbs update проверяет первым EOF } @@ -55,34 +58,172 @@ cmd_domain() { grep "^PANEL_DOMAIN=" "$ENV_FILE" } -cmd_update() { - cd "$APP_DIR" - echo "проверяю обновления..." - local remote="origin" - if ! git fetch --quiet origin main 2>/dev/null; then - if git remote | grep -q '^github$'; then - echo "зеркало недоступно, пробую github..." - if ! git fetch --quiet github main 2>/dev/null; then - echo "не удалось получить обновления ни с зеркала, ни с github" +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 - remote="github" + 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 "не удалось получить обновления" + echo "не удалось получить обновления ни с зеркала, ни с github" return 1 fi fi - local before after before=$(git rev-parse HEAD) - after=$(git rev-parse "$remote/main") - if [ "$before" = "$after" ]; then + 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 "новая: $after" - if ! git merge --ff-only "$remote/main" --quiet; then - echo "не вышло быстро обновиться (похоже, файлы правились вручную на сервере) — разберись руками: git status" + 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 "обновляю зависимости..." @@ -92,6 +233,7 @@ cmd_update() { echo "новый код не проходит проверку, откатываюсь на $before..." git reset --hard "$before" --quiet venv/bin/pip install --quiet -r requirements.txt + restore_stash return 1 fi echo "обновляю сам CLI..." @@ -115,22 +257,36 @@ cmd_update() { 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 ;; + update) cmd_update "$2" ;; + mirror) cmd_mirror "$2" ;; + backup) cmd_backup ;; *) usage ;; esac +} + +main "$@"; exit $? diff --git a/tests/test_mbs_update.sh b/tests/test_mbs_update.sh new file mode 100644 index 0000000..f06b27e --- /dev/null +++ b/tests/test_mbs_update.sh @@ -0,0 +1,155 @@ +#!/bin/bash +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +T=$(mktemp -d) +cd "$T" +export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.autocrlf GIT_CONFIG_VALUE_0=false +export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t +mkdir -p stub bin sysd +cat > stub/systemctl << 'EOF' +#!/bin/sh +exit 0 +EOF +chmod +x stub/systemctl +export PATH="$T/stub:$PATH" +export MBS_BACKUP_DIR="$T/backups" + +PASS=0 +FAIL=0 +ok() { echo "PASS $1"; PASS=$((PASS+1)); } +bad() { echo "FAIL $1 :: $2"; FAIL=$((FAIL+1)); } +check() { if eval "$2"; then ok "$1"; else bad "$1" "$3"; fi; } + +sed -e "s#APP_DIR=\"/opt/mbs-panel\"#APP_DIR=\"$T/app\"#" -e "s#/usr/local/bin/mbs#$T/bin/mbs#g" -e "s#/etc/systemd/system#$T/sysd#g" "$REPO/mbs" > "$T/mbs-run" + +git init -q --bare -b main origin.git +git clone -q origin.git seed 2>/dev/null +cd seed +git checkout -q -b main 2>/dev/null || true +mkdir -p systemd +cp "$REPO/mbs" mbs +echo "v1" > bot.py +echo "v1" > config.py +echo "v1" > db.py +echo "v1" > settings.py +echo "x" > requirements.txt +echo "[Service]" > systemd/mbs-bot.service +echo "ExecStart=x --workers __WORKERS__" > systemd/mbs-api.service +cp "$REPO/.gitignore" .gitignore +git add -A && git commit -q -m A && git push -q origin main +cd "$T" +git clone -q origin.git app +mkdir -p app/venv/bin +printf '#!/bin/sh\nexit 0\n' > app/venv/bin/pip +printf '#!/bin/sh\nexec python "$@"\n' > app/venv/bin/python +chmod +x app/venv/bin/pip app/venv/bin/python +echo "SECRET=1" > app/.env +python -c "import sqlite3,sys; c=sqlite3.connect(sys.argv[1]); c.execute('pragma journal_mode=wal'); c.execute('create table t(x)'); c.execute('insert into t values (42)'); c.commit(); c.close()" app/mbs.db + +cd seed && echo "v2" > bot.py && echo "v2" > db.py && git commit -qam B && git push -q origin main && cd "$T" + +echo "manual edit" >> app/bot.py +echo "manual edit" >> app/config.py +echo "manual edit" >> app/db.py +echo "manual edit" >> app/settings.py +OUT=$(bash "$T/mbs-run" update 2>&1); RC=$? +echo "$OUT" > out1.txt +check "update succeeds despite manual edits on the server (the reported bug)" "[ $RC -eq 0 ]" "rc=$RC $OUT" +check "bot.py is the new version" "[ \"\$(cat app/bot.py)\" = v2 ]" "$(cat app/bot.py)" +check "a stash with the manual edits exists" "[ \$(git -C app stash list | wc -l) -eq 1 ]" +check "patch with the manual edits saved" "ls app/local-changes/*.patch >/dev/null 2>&1 && grep -q 'manual edit' app/local-changes/*.patch" +check "user is told where the edits went" "grep -q 'ручные правки' out1.txt && grep -q 'патч' out1.txt" +check "working tree clean after update" "[ -z \"\$(git -C app status --porcelain --untracked-files=no)\" ]" +check "cli copied" "[ -f bin/mbs ]" +check "a pre-update backup was made" "[ \$(ls backups/mbs-before-update-*.tar.gz 2>/dev/null | wc -l) -eq 1 ]" "$(ls backups 2>&1)" +check "update output points at the backup" "grep -q 'резервная копия перед обновлением' out1.txt" +mkdir -p unpack && tar xzf backups/mbs-before-update-*.tar.gz -C unpack +APPREL="${T#/}/app" +check "backup keeps .env" "grep -q SECRET=1 unpack/$APPREL/.env" +check "backup keeps the manual edits as they were before the update" "grep -q 'manual edit' unpack/$APPREL/bot.py && grep -q 'manual edit' unpack/$APPREL/config.py" +check "backup database is a consistent sqlite copy" "[ \"\$(python -c \"import sqlite3,sys; print(sqlite3.connect(sys.argv[1]).execute('select x from t').fetchone()[0])\" unpack/$APPREL/mbs.db)\" = 42 ]" +check "backup does not drag the venv along" "[ ! -d unpack/$APPREL/venv ]" +check "no snapshot temp file is left behind" "[ ! -e app/.mbs.db.snapshot ]" +OUT=$(bash "$T/mbs-run" backup 2>&1); RC=$? +check "mbs backup makes a manual copy" "[ $RC -eq 0 ] && ls backups/mbs-manual-*.tar.gz >/dev/null 2>&1" "$OUT" +for i in 1 2 3 4 5 6 7; do sleep 1.1; bash "$T/mbs-run" backup >/dev/null 2>&1; done +check "only the 5 newest backups are kept" "[ \$(ls backups/mbs-*.tar.gz | wc -l) -eq 5 ]" "$(ls backups | wc -l)" + +OUT=$(bash "$T/mbs-run" update 2>&1); echo "$OUT" > out2.txt +check "second run says already latest" "grep -q 'уже последняя' out2.txt" "$OUT" + +cd "$T/app" && git stash drop -q && git reset -q --hard HEAD; cd "$T" + +git clone -q --bare origin.git mirror2.git +cd seed && git pull -q origin main 2>/dev/null; echo "v3" > bot.py && git commit -qam C && git push -q "$T/mirror2.git" main && cd "$T" +git -C mirror2.git update-server-info +python -m http.server 8799 --directory "$T" > http.log 2>&1 & +HTTP_PID=$! +sleep 1.5 +OUT=$(bash "$T/mbs-run" update "http://127.0.0.1:8799/mirror2.git" 2>&1); RC=$? +echo "$OUT" > out3.txt +check "update by mirror url works" "[ $RC -eq 0 ] && [ \"\$(cat app/bot.py)\" = v3 ]" "rc=$RC $OUT" +check "url run mentions the source and how to save it" "grep -q 'источник: http://127.0.0.1:8799/mirror2.git' out3.txt && grep -q 'mbs mirror http' out3.txt" "$OUT" +check "url alone is not saved automatically" "[ ! -f app/.update_mirror ]" + +bash "$T/mbs-run" mirror "http://127.0.0.1:8799/mirror2.git" > out4.txt 2>&1 +check "mirror command saves the url" "grep -q 'http://127.0.0.1:8799/mirror2.git' app/.update_mirror" +check "mirror file is git-ignored" "[ -z \"\$(git -C app status --porcelain)\" ]" "$(git -C app status --porcelain)" +bash "$T/mbs-run" mirror > out5.txt 2>&1 +check "mirror shows the saved url" "grep -q 'своё зеркало для обновлений: http://127.0.0.1:8799' out5.txt" + +cd seed && echo "v4" > bot.py && git commit -qam D && git push -q "$T/mirror2.git" main && cd "$T" +git -C mirror2.git update-server-info +OUT=$(bash "$T/mbs-run" update 2>&1); RC=$? +echo "$OUT" > out6.txt +check "plain update prefers the saved mirror" "[ $RC -eq 0 ] && grep -q 'источник: своё зеркало' out6.txt && [ \"\$(cat app/bot.py)\" = v4 ]" "rc=$RC $OUT" + +bash "$T/mbs-run" mirror off > out7.txt 2>&1 +check "mirror off removes it" "[ ! -f app/.update_mirror ]" + +for bad_url in "ext::sh -c 'touch $T/pwned'" "-uHEAD" "file:///etc" "ftp://x/y" "https://a b/c" "--upload-pack=touch $T/pwned2"; do + OUT=$(bash "$T/mbs-run" update "$bad_url" 2>&1); RC=$? + check "rejects unsafe source '$bad_url'" "[ $RC -ne 0 ] && grep -q 'не похоже на ссылку' <<< \"\$OUT\"" "rc=$RC $OUT" +done +check "no command was executed via a crafted url" "[ ! -e pwned ] && [ ! -e pwned2 ]" +OUT=$(bash "$T/mbs-run" mirror "file:///etc" 2>&1); RC=$? +check "mirror refuses unsafe urls too" "[ $RC -ne 0 ] && [ ! -f app/.update_mirror ]" + +OUT=$(bash "$T/mbs-run" update "http://127.0.0.1:1/nope.git" 2>&1); RC=$? +check "unreachable mirror fails cleanly" "[ $RC -ne 0 ] && grep -q 'не удалось получить обновления по ссылке' <<< \"\$OUT\"" "rc=$RC $OUT" + +cd seed && echo "v5" > bot.py && echo "broken(" > broken.py && git add -A && git commit -qm E && git push -q origin main && cd "$T" +git -C app fetch -q origin main +git -C app merge -q --ff-only origin/main~1 2>/dev/null || true +cd app && git reset -q --hard origin/main~1 2>/dev/null; cd "$T" +echo "local tweak" >> app/settings.py +BEFORE=$(git -C app rev-parse HEAD) +OUT=$(bash "$T/mbs-run" update 2>&1); RC=$? +echo "$OUT" > out8.txt +check "broken new code is rolled back" "[ $RC -ne 0 ] && grep -q 'откатываюсь' out8.txt && [ \"\$(git -C app rev-parse HEAD)\" = \"$BEFORE\" ]" "rc=$RC $OUT" +check "manual edits are restored after the rollback" "grep -q 'local tweak' app/settings.py && [ \$(git -C app stash list | wc -l) -eq 0 ]" "$(git -C app stash list)" + +cd app && git checkout -q -- . && git reset -q --hard origin/main~1 && cd "$T" +cd app && echo "own" > own.txt && git add own.txt && git commit -qm "local commit" && cd "$T" +cd seed && git rm -q broken.py && echo "v6" > bot.py && git commit -qam F && git push -q origin main && cd "$T" +OUT=$(bash "$T/mbs-run" update 2>&1); RC=$? +echo "$OUT" > out9.txt +check "diverged server history is refused, not merged" "[ $RC -ne 0 ] && grep -q 'свои коммиты' out9.txt" "rc=$RC $OUT" +check "refused update leaves the local commit alone" "git -C app log --oneline | grep -q 'local commit'" + +cd app && git reset -q --hard origin/main && echo "ahead" > ahead.txt && git add ahead.txt && git commit -qm ahead && cd "$T" +OUT=$(bash "$T/mbs-run" update 2>&1); RC=$? +check "server newer than the source is left alone" "[ $RC -eq 0 ] && grep -q 'версия новее' <<< \"\$OUT\"" "rc=$RC $OUT" + +cd "$T/app" && git reset -q --hard origin/main && cd "$T" +cd seed && echo "v7" > bot.py && git commit -qam G && git push -q origin main && cd "$T" +BEFORE=$(git -C app rev-parse HEAD) +echo "x" > notadir +OUT=$(MBS_BACKUP_DIR="$T/notadir/x" bash "$T/mbs-run" update 2>&1); RC=$? +check "update refuses to start when no backup can be made" "[ $RC -ne 0 ] && grep -q 'не начинаю' <<< \"\$OUT\" && [ \"\$(git -C app rev-parse HEAD)\" = \"$BEFORE\" ]" "rc=$RC $OUT" +check "refused update leaves the code untouched" "[ \"\$(cat app/bot.py)\" != v7 ]" + +kill $HTTP_PID 2>/dev/null +cd / +rm -rf "$T" +echo "RESULT pass=$PASS fail=$FAIL" +[ "$FAIL" -eq 0 ]