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_hint') }} +
+{{ _('restore_confirm') }}