diff --git a/Dockerfile b/Dockerfile index 43b72ed..b4a579e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app RUN apt-get update \ - && apt-get install -y --no-install-recommends curl \ + && apt-get install -y --no-install-recommends curl postgresql-client \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . diff --git a/README.md b/README.md index 9387a87..f0f501f 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,9 @@ GitHub Actions workflows in `.github/workflows/`: ## 📋 Fix / changelog (this fork) +### v2.6.2 +* **Panel backup → PostgreSQL dump** — Settings backup downloads `.sql` via `pg_dump`; restore accepts `.sql` or legacy `data.json`. JSON export kept as secondary link. + ### v2.6.1 * **Move connections between servers** — restored: select configs on a server page and move to another server (recreates peers, keeps user links). `ToggleConnectionRequest` kept intact. diff --git a/app.py b/app.py index a5c98ac..4a532a0 100644 --- a/app.py +++ b/app.py @@ -103,7 +103,7 @@ else: application_path = os.path.dirname(__file__) DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export -CURRENT_VERSION = "v2.6.1" +CURRENT_VERSION = "v2.6.2" RELEASES_REPO_URL = repo_url() RELEASES_API_LATEST = api_latest_url() BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin')) @@ -111,11 +111,14 @@ TUNNEL_STATE_FILE = os.environ.get('TUNNEL_STATE_FILE', os.path.join(application # Panel state lives in PostgreSQL 17 (see db/). load_data/save_data keep the old dict API. from db import ( # noqa: E402 + backup_filename, clear_tunnel_state, ensure_db_ready, export_data_dict, + export_database_sql, load_data, load_tunnel_state, + restore_database_sql, save_data, save_tunnel_state, update_tunnel_state, @@ -6591,6 +6594,24 @@ async def api_revoke_token(request: Request, token_id: str): @app.get('/api/settings/backup/download', tags=["Settings"]) async def api_backup_download(request: Request): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + content = await asyncio.to_thread(export_database_sql) + except Exception as e: + logger.exception('Database backup failed') + return JSONResponse({'error': str(e)}, status_code=500) + filename = backup_filename() + return StreamingResponse( + io.BytesIO(content), + media_type='application/sql', + headers={'Content-Disposition': f'attachment; filename="{filename}"'}, + ) + + +@app.get('/api/settings/backup/download/json', tags=["Settings"]) +async def api_backup_download_json(request: Request): + """Legacy JSON export (same shape as old data.json).""" if not _check_admin(request): return JSONResponse({'error': 'Forbidden'}, status_code=403) payload = await asyncio.to_thread(export_data_dict) @@ -6610,33 +6631,39 @@ async def api_backup_restore(request: Request, file: UploadFile = File(...)): content = await file.read() if not content: return JSONResponse({'error': 'Empty file'}, status_code=400) - + + filename = (file.filename or '').lower() + is_json = filename.endswith('.json') or content.lstrip().startswith(b'{') + + if is_json: + try: + backup_data = json.loads(content) + except json.JSONDecodeError: + return JSONResponse({'error': 'Invalid JSON format'}, status_code=400) + + required_keys = ['servers', 'users'] + missing = [k for k in required_keys if k not in backup_data] + if missing: + return JSONResponse({'error': f'Invalid structure. Missing keys: {", ".join(missing)}'}, status_code=400) + + if not isinstance(backup_data['servers'], list) or not isinstance(backup_data['users'], list): + return JSONResponse({'error': 'Invalid structure: servers and users must be lists'}, status_code=400) + + backup_data.setdefault('user_connections', []) + backup_data.setdefault('api_tokens', []) + backup_data.setdefault('invite_links', []) + backup_data.setdefault('settings', {}) + + async with DATA_LOCK: + save_data(backup_data) + return {'status': 'success', 'format': 'json'} + try: - backup_data = json.loads(content) - except json.JSONDecodeError: - return JSONResponse({'error': 'Invalid JSON format'}, status_code=400) - - # Basic structure validation - required_keys = ['servers', 'users'] - missing = [k for k in required_keys if k not in backup_data] - if missing: - return JSONResponse({'error': f'Invalid structure. Missing keys: {", ".join(missing)}'}, status_code=400) - - # Ensure types are correct - if not isinstance(backup_data['servers'], list) or not isinstance(backup_data['users'], list): - return JSONResponse({'error': 'Invalid structure: servers and users must be lists'}, status_code=400) - - backup_data.setdefault('user_connections', []) - backup_data.setdefault('api_tokens', []) - backup_data.setdefault('invite_links', []) - backup_data.setdefault('settings', {}) - - # Save the new data - async with DATA_LOCK: - save_data(backup_data) - - # In a real app we might want to restart or re-init background tasks - return {'status': 'success'} + await asyncio.to_thread(restore_database_sql, content) + except Exception as e: + logger.exception('Database restore failed') + return JSONResponse({'error': str(e)}, status_code=400) + return {'status': 'success', 'format': 'sql'} except Exception as e: logger.exception("Error during restore") return JSONResponse({'error': str(e)}, status_code=500) diff --git a/db/__init__.py b/db/__init__.py index d237706..6e457ca 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -1,5 +1,6 @@ """Database package for Amnezia Web Panel (PostgreSQL 17).""" +from .backup import backup_filename, export_database_sql, restore_database_sql from .connection import close_pool, get_database_url, init_schema from .store import ( clear_tunnel_state, @@ -14,15 +15,18 @@ from .store import ( ) __all__ = [ + 'backup_filename', 'clear_tunnel_state', 'close_pool', 'ensure_db_ready', 'export_data_dict', + 'export_database_sql', 'get_database_url', 'import_from_json_file', 'init_schema', 'load_data', 'load_tunnel_state', + 'restore_database_sql', 'save_data', 'save_tunnel_state', 'update_tunnel_state', diff --git a/db/backup.py b/db/backup.py new file mode 100644 index 0000000..f9195fd --- /dev/null +++ b/db/backup.py @@ -0,0 +1,59 @@ +"""PostgreSQL backup/restore for the panel database.""" + +from __future__ import annotations + +import logging +import subprocess +from datetime import datetime, timezone + +from .connection import get_database_url +from .store import invalidate_data_cache + +logger = logging.getLogger(__name__) + + +def backup_filename() -> str: + stamp = datetime.now(timezone.utc).strftime('%Y-%m-%d_%H%M%S') + return f'amnezia_panel_backup_{stamp}.sql' + + +def export_database_sql() -> bytes: + """Create a plain SQL dump of the panel PostgreSQL database.""" + url = get_database_url() + proc = subprocess.run( + [ + 'pg_dump', + '--dbname', url, + '--no-owner', + '--no-acl', + '--clean', + '--if-exists', + ], + capture_output=True, + check=False, + ) + if proc.returncode != 0: + err = proc.stderr.decode('utf-8', errors='replace').strip() + raise RuntimeError(err or 'pg_dump failed') + if not proc.stdout: + raise RuntimeError('pg_dump returned empty dump') + return proc.stdout + + +def restore_database_sql(data: bytes) -> None: + """Restore panel data from a plain SQL dump produced by pg_dump.""" + if not data or not data.strip(): + raise ValueError('Empty backup file') + url = get_database_url() + proc = subprocess.run( + ['psql', '--dbname', url, '-v', 'ON_ERROR_STOP=1', '-q'], + input=data, + capture_output=True, + check=False, + ) + if proc.returncode != 0: + err = proc.stderr.decode('utf-8', errors='replace').strip() + out = proc.stdout.decode('utf-8', errors='replace').strip() + raise RuntimeError(err or out or 'psql restore failed') + invalidate_data_cache() + logger.info('PostgreSQL backup restored successfully') diff --git a/templates/settings.html b/templates/settings.html index f7ff06c..2e2222e 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -604,15 +604,19 @@

📤 {{ _('backup_title') }}

-
+
+ style="flex:1; min-width:200px; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);"> ⬇️ {{ _('download_backup') }} + + 📄 {{ _('download_backup_json') }} +
-

+ {{ _('backup_hint') }} +

+

{{ _('restore_confirm') }}

diff --git a/translations/en.json b/translations/en.json index e1747df..d09838a 100644 --- a/translations/en.json +++ b/translations/en.json @@ -361,11 +361,13 @@ "lang_zh": "中文 (Chinese)", "lang_fa": "فارسی (Persian)", "backup_title": "Simple Backup", - "download_backup": "Download data.json", - "restore_backup": "Restore from file", - "restore_confirm": "Are you sure? Current data will be overwritten and the application will restart.", + "download_backup": "Download PostgreSQL dump (.sql)", + "download_backup_json": "Export JSON (legacy)", + "backup_hint": "Full PostgreSQL database dump of the panel. Use .sql for complete backup; JSON export is compatible with older versions.", + "restore_backup": "Restore from .sql or .json", + "restore_confirm": "Restore will overwrite all current panel data in the database.", "restore_success": "Restore successful! Restarting...", - "invalid_backup_file": "Invalid backup file or structure", + "invalid_backup_file": "Invalid backup file (.sql dump or legacy data.json)", "config_unavailable": "Configuration unavailable", "config_unavailable_desc": "This client was created via the native Amnezia app.\\nThe private key is stored only on the user\u0027s device and cannot be recovered by the server.", "client_public_key": "Client public key:", diff --git a/translations/fa.json b/translations/fa.json index c53422b..bb20ede 100644 --- a/translations/fa.json +++ b/translations/fa.json @@ -344,11 +344,13 @@ "lang_zh": "中文 (Chinese)", "lang_fa": "فارسی (Persian)", "backup_title": "پشتیبان‌گیری ساده", - "download_backup": "دانلود data.json", - "restore_backup": "بازیابی از فایل", - "restore_confirm": "آیا مطمئن هستید؟ داده‌های فعلی بازنویسی می‌شوند و برنامه دوباره راه‌اندازی خواهد شد.", + "download_backup": "دانلود dump PostgreSQL (.sql)", + "download_backup_json": "خروجی JSON (قدیمی)", + "backup_hint": "Dump کامل پایگاه داده PostgreSQL پنل. برای پشتیبان کامل از .sql استفاده کنید؛ JSON برای نسخه‌های قدیمی.", + "restore_backup": "بازیابی از .sql یا .json", + "restore_confirm": "بازیابی تمام داده‌های فعلی پنل در پایگاه داده را بازنویسی می‌کند.", "restore_success": "بازیابی موفقیت‌آمیز بود! در حال راه‌اندازی مجدد...", - "invalid_backup_file": "فایل پشتیبان یا ساختار نامعتبر است", + "invalid_backup_file": "فایل نامعتبر (dump .sql یا data.json قدیمی)", "config_unavailable": "پیکربندی در دسترس نیست", "config_unavailable_desc": "این کلاینت از طریق اپلیکیشن اصلی Amnezia ایجاد شده است.\\nکلید خصوصی فقط در دستگاه کاربر ذخیره می‌شود و توسط سرور قابل بازیابی نیست.", "client_public_key": "کلید عمومی کلاینت:", diff --git a/translations/fr.json b/translations/fr.json index 905f40d..604a612 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -344,11 +344,13 @@ "lang_zh": "中文 (Chinese)", "lang_fa": "فارسی (Persian)", "backup_title": "Sauvegarde Simple", - "download_backup": "Télécharger data.json", - "restore_backup": "Restaurer depuis un fichier", - "restore_confirm": "Êtes-vous sûr ? Les données actuelles seront écrasées et l\u0027application redémarrera.", + "download_backup": "Télécharger le dump PostgreSQL (.sql)", + "download_backup_json": "Exporter JSON (ancien)", + "backup_hint": "Dump complet de la base PostgreSQL du panneau. Utilisez .sql pour une sauvegarde complète ; JSON pour l'ancien format.", + "restore_backup": "Restaurer depuis .sql ou .json", + "restore_confirm": "La restauration écrasera toutes les données actuelles du panneau dans la base.", "restore_success": "Restauration réussie ! Redémarrage...", - "invalid_backup_file": "Fichier de sauvegarde ou structure invalide", + "invalid_backup_file": "Fichier invalide (dump .sql ou ancien data.json)", "config_unavailable": "Configuration indisponible", "config_unavailable_desc": "Ce client a été créé via l\u0027application native Amnezia.\\nLa clé privée est stockée uniquement sur l\u0027appareil de l\u0027utilisateur et ne peut pas être récupérée par le serveur.", "client_public_key": "Clé publique du client :", diff --git a/translations/ru.json b/translations/ru.json index d0fd6aa..452ebd4 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -361,11 +361,13 @@ "lang_zh": "中文 (Chinese)", "lang_fa": "فارسی (Persian)", "backup_title": "Резервное копирование", - "download_backup": "Скачать data.json", - "restore_backup": "Восстановить из файла", - "restore_confirm": "Вы уверены? Текущие данные будут перезаписаны, и приложение перезагрузится.", + "download_backup": "Скачать дамп PostgreSQL (.sql)", + "download_backup_json": "Экспорт JSON (устар.)", + "backup_hint": "Полный дамп базы данных панели. Для бэкапа используйте .sql; JSON — для совместимости со старыми версиями.", + "restore_backup": "Восстановить из .sql или .json", + "restore_confirm": "Восстановление перезапишет все текущие данные панели в базе.", "restore_success": "Восстановление успешно! Перезагрузка...", - "invalid_backup_file": "Неверный файл резервной копии или структура", + "invalid_backup_file": "Неверный файл (.sql дамп или устаревший data.json)", "config_unavailable": "Конфигурация недоступна", "config_unavailable_desc": "Этот клиент был создан через нативное приложение Amnezia.\\nПриватный ключ хранится только на устройстве пользователя и не может быть восстановлен сервером.", "client_public_key": "Публичный ключ клиента:", diff --git a/translations/zh.json b/translations/zh.json index 7e8f042..453d333 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -344,11 +344,13 @@ "lang_zh": "中文 (Chinese)", "lang_fa": "فارسی (Persian)", "backup_title": "简易备份", - "download_backup": "下载 data.json", - "restore_backup": "从文件恢复", - "restore_confirm": "您确定吗?当前数据将被覆盖,应用程序将重新启动。", + "download_backup": "下载 PostgreSQL 转储 (.sql)", + "download_backup_json": "导出 JSON(旧版)", + "backup_hint": "面板 PostgreSQL 数据库的完整转储。请使用 .sql 进行完整备份;JSON 用于兼容旧版本。", + "restore_backup": "从 .sql 或 .json 恢复", + "restore_confirm": "恢复将覆盖数据库中所有当前面板数据。", "restore_success": "恢复成功!正在重启...", - "invalid_backup_file": "备份文件无效或结构错误", + "invalid_backup_file": "无效的备份文件(.sql 转储或旧版 data.json)", "config_unavailable": "配置文件不可用", "config_unavailable_desc": "此客户端是通过 Amnezia 原生应用创建的。\\n私钥仅存储在用户设备上,服务器无法恢复。", "client_public_key": "客户端公钥:",