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

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)