feat: backup & restore built into the admin panel

Neither Remnawave nor Marzban has this natively (community tools only,
per docs.rw's own comparison table) — one-click download of a tar.gz
with a consistent SQLite snapshot (via sqlite3's backup API, safe even
under WAL) plus .env, and upload-to-restore from the same file.

Restore validates the archive is real (gzip + tar structure), that
mbs.db is an actual sqlite database with the expected tables (not
just any file named mbs.db), and rejects oversized uploads — before
touching anything live. Takes a timestamped safety copy of the
current db/.env before overwriting, clears stale -wal/-shm siblings
so the restored file doesn't get replayed against the wrong WAL, and
restarts mbs-bot automatically when .env was part of the restore
(api.py isn't restarted from within its own request handler for the
obvious reason).

Verified with a full round-trip test: backup -> mutate state -> restore
-> confirm the mutation is reverted, plus three negative cases (garbage
data, oversized upload, a fake non-sqlite mbs.db) all correctly
rejected with no side effects.

Needs python-multipart for FastAPI's UploadFile — added to
requirements.txt, picked up by the next 'mbs update'.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Savsis? 2026-09-12 10:23:38 +05:00
parent cfac7c1445
commit f586cf9fa3
4 changed files with 188 additions and 1 deletions

View file

@ -535,6 +535,23 @@
<p class="check-hint">Панель сама проверит токен у Telegram (запрос getMe) перед применением и подставит настоящий юзернейм бота — придумывать не нужно. После смены перезапустится только бот; если уведомления от api (например об оплате) продолжат идти со старого бота, выполни на сервере <code>mbs restart</code>.</p>
<div id="settings-bot-result"></div>
</div>
<div class="section" style="margin-top:20px">
<div class="section-head"><h2>Бэкап и восстановление</h2></div>
<p class="page-sub" style="margin-bottom:16px">Бэкап — это база (юзеры, подписки, ноды, платежи) и <code>.env</code> одним файлом. Держи копии где-то отдельно от сервера.</p>
<div class="form-row" style="align-items:flex-start">
<button class="btn" onclick="downloadBackup()">Скачать бэкап</button>
</div>
<div style="margin-top:20px;padding-top:20px;border-top:1px solid var(--border)">
<label class="f">Восстановить из файла</label>
<div class="form-row">
<input type="file" id="restore-file-input" accept=".gz,.tar.gz">
<div style="flex:0"><button class="btn" style="background:var(--red)" onclick="restoreBackup()">Восстановить</button></div>
</div>
<p class="check-hint">⚠ Заменяет текущую базу целиком. Перед заменой панель сама делает копию текущей базы на сервере (файл <code>.before-restore-...</code>), но проверь, что заливаешь именно тот файл, что нужно.</p>
</div>
<div id="backup-result"></div>
</div>
</div>
</div>
</div>
@ -867,6 +884,31 @@ async function saveBotSettings() {
}
}
function downloadBackup() {
window.location.href = "/admin/api/backup";
}
async function restoreBackup() {
const input = document.getElementById("restore-file-input");
const result = document.getElementById("backup-result");
const file = input.files[0];
if (!file) return;
if (!confirm("Заменить текущую базу файлом " + file.name + "? Текущая база сохранится в файл .before-restore-... на сервере, но действие лучше не отменять просто так.")) return;
result.innerHTML = '<p class="page-sub" style="margin-top:10px">Восстанавливаю…</p>';
try {
const form = new FormData();
form.append("file", file);
const res = await fetch("/admin/api/backup/restore", { method: "POST", body: form });
if (res.status === 401) { showLogin(); return; }
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
result.innerHTML = `<p class="page-sub" style="margin-top:10px;color:var(--green)">Готово. Копия старой базы: <code>${esc(data.safety_copy)}</code>.${data.restored_env ? (data.restarted_bot ? " Бот перезапущен с новым .env." : " .env восстановлен, но бот сам не перезапустился — выполни mbs restart.") : ""}</p>`;
input.value = "";
} catch (e) {
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
}
}
function fmtDate(iso) { return iso ? iso.slice(0, 10) : "—"; }
function statusBadge(active, daysLeft) {
if (!active) return '<span class="badge bad">истекла</span>';

32
api.py
View file

@ -5,11 +5,12 @@ import os
import re
import subprocess
import urllib.request
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi import FastAPI, HTTPException, Request, Response, File, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi import Body
from fastapi.responses import HTMLResponse, PlainTextResponse, FileResponse
import backup
import db
import links
import nodeprov
@ -490,6 +491,35 @@ def admin_set_bot_settings(request: Request, body: dict = Body(...)):
return {"ok": True, "username": username, "restarted": restarted}
@app.get("/admin/api/backup")
def admin_download_backup(request: Request):
require_admin(request)
data = backup.create_backup()
filename = f"mbs-backup-{datetime.datetime.utcnow().strftime('%Y%m%d-%H%M%S')}.tar.gz"
return Response(
content=data, media_type="application/gzip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@app.post("/admin/api/backup/restore")
async def admin_restore_backup(request: Request, file: UploadFile = File(...)):
require_admin(request)
data = await file.read()
try:
result = backup.restore_backup(data)
except backup.RestoreError as e:
raise HTTPException(400, str(e))
restarted_bot = False
if result["restored_env"]:
try:
subprocess.run(["systemctl", "restart", "mbs-bot"], check=True, timeout=15)
restarted_bot = True
except Exception:
restarted_bot = False
return {"ok": True, **result, "restarted_bot": restarted_bot}
@app.get("/admin/api/stats")
def admin_stats(request: Request):
require_admin(request)

114
backup.py Normal file
View file

@ -0,0 +1,114 @@
import io
import json
import os
import shutil
import sqlite3
import tarfile
import datetime
from config import DB_PATH, BASE_DIR
ENV_PATH = os.path.join(BASE_DIR, ".env")
MAX_RESTORE_SIZE = 200 * 1024 * 1024
class RestoreError(Exception):
pass
def create_backup() -> bytes:
buf = io.BytesIO()
db_tmp = DB_PATH + ".backup_snapshot.tmp"
src = sqlite3.connect(DB_PATH)
dst = sqlite3.connect(db_tmp)
try:
with dst:
src.backup(dst)
finally:
src.close()
dst.close()
try:
files = ["mbs.db"]
if os.path.exists(ENV_PATH):
files.append(".env")
manifest = {
"created_at": datetime.datetime.utcnow().isoformat(),
"files": files,
}
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
tar.add(db_tmp, arcname="mbs.db")
if os.path.exists(ENV_PATH):
tar.add(ENV_PATH, arcname=".env")
manifest_bytes = json.dumps(manifest, indent=2).encode()
info = tarfile.TarInfo(name="manifest.json")
info.size = len(manifest_bytes)
tar.addfile(info, io.BytesIO(manifest_bytes))
finally:
try:
os.remove(db_tmp)
except OSError:
pass
return buf.getvalue()
def restore_backup(data: bytes) -> dict:
if len(data) > MAX_RESTORE_SIZE:
raise RestoreError("backup file too large")
try:
tar = tarfile.open(fileobj=io.BytesIO(data), mode="r:gz")
except Exception as e:
raise RestoreError(f"not a valid backup archive: {e}")
members = {m.name: m for m in tar.getmembers()}
if "mbs.db" not in members:
raise RestoreError("archive has no mbs.db")
tmp_db_path = DB_PATH + ".restore_candidate.tmp"
db_member = tar.extractfile(members["mbs.db"])
with open(tmp_db_path, "wb") as f:
shutil.copyfileobj(db_member, f)
try:
check_conn = sqlite3.connect(tmp_db_path)
try:
tables = {r[0] for r in check_conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
finally:
check_conn.close()
except sqlite3.DatabaseError as e:
os.remove(tmp_db_path)
raise RestoreError(f"archive's mbs.db is not a valid sqlite database: {e}")
required = {"users", "subscriptions", "nodes", "payments"}
if not required.issubset(tables):
os.remove(tmp_db_path)
raise RestoreError("archive's mbs.db is missing expected tables")
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S")
safety_copy = f"{DB_PATH}.before-restore-{stamp}"
shutil.copy2(DB_PATH, safety_copy)
restored_env = False
if ".env" in members and os.path.exists(ENV_PATH):
env_safety = f"{ENV_PATH}.before-restore-{stamp}"
shutil.copy2(ENV_PATH, env_safety)
env_member = tar.extractfile(members[".env"])
env_tmp = ENV_PATH + ".restore.tmp"
with open(env_tmp, "wb") as f:
shutil.copyfileobj(env_member, f)
os.chmod(env_tmp, 0o600)
os.replace(env_tmp, ENV_PATH)
restored_env = True
for suffix in ("-wal", "-shm"):
try:
os.remove(DB_PATH + suffix)
except OSError:
pass
os.chmod(tmp_db_path, 0o600)
os.replace(tmp_db_path, DB_PATH)
return {"restored_env": restored_env, "safety_copy": safety_copy}

View file

@ -2,3 +2,4 @@ aiogram==3.15.0
fastapi==0.115.6
uvicorn[standard]==0.32.1
paramiko==3.5.0
python-multipart==0.0.20