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:
parent
cfac7c1445
commit
f586cf9fa3
4 changed files with 188 additions and 1 deletions
42
admin.html
42
admin.html
|
|
@ -535,6 +535,23 @@
|
||||||
<p class="check-hint">Панель сама проверит токен у Telegram (запрос getMe) перед применением и подставит настоящий юзернейм бота — придумывать не нужно. После смены перезапустится только бот; если уведомления от api (например об оплате) продолжат идти со старого бота, выполни на сервере <code>mbs restart</code>.</p>
|
<p class="check-hint">Панель сама проверит токен у Telegram (запрос getMe) перед применением и подставит настоящий юзернейм бота — придумывать не нужно. После смены перезапустится только бот; если уведомления от api (например об оплате) продолжат идти со старого бота, выполни на сервере <code>mbs restart</code>.</p>
|
||||||
<div id="settings-bot-result"></div>
|
<div id="settings-bot-result"></div>
|
||||||
</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>
|
</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 fmtDate(iso) { return iso ? iso.slice(0, 10) : "—"; }
|
||||||
function statusBadge(active, daysLeft) {
|
function statusBadge(active, daysLeft) {
|
||||||
if (!active) return '<span class="badge bad">истекла</span>';
|
if (!active) return '<span class="badge bad">истекла</span>';
|
||||||
|
|
|
||||||
32
api.py
32
api.py
|
|
@ -5,11 +5,12 @@ import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import urllib.request
|
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.middleware.cors import CORSMiddleware
|
||||||
from fastapi import Body
|
from fastapi import Body
|
||||||
from fastapi.responses import HTMLResponse, PlainTextResponse, FileResponse
|
from fastapi.responses import HTMLResponse, PlainTextResponse, FileResponse
|
||||||
|
|
||||||
|
import backup
|
||||||
import db
|
import db
|
||||||
import links
|
import links
|
||||||
import nodeprov
|
import nodeprov
|
||||||
|
|
@ -490,6 +491,35 @@ def admin_set_bot_settings(request: Request, body: dict = Body(...)):
|
||||||
return {"ok": True, "username": username, "restarted": restarted}
|
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")
|
@app.get("/admin/api/stats")
|
||||||
def admin_stats(request: Request):
|
def admin_stats(request: Request):
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
|
|
|
||||||
114
backup.py
Normal file
114
backup.py
Normal 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}
|
||||||
|
|
@ -2,3 +2,4 @@ aiogram==3.15.0
|
||||||
fastapi==0.115.6
|
fastapi==0.115.6
|
||||||
uvicorn[standard]==0.32.1
|
uvicorn[standard]==0.32.1
|
||||||
paramiko==3.5.0
|
paramiko==3.5.0
|
||||||
|
python-multipart==0.0.20
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue