From 3c95094fc881b91160d3998b1ee5debb7baf01df Mon Sep 17 00:00:00 2001 From: orohi Date: Wed, 29 Jul 2026 11:04:19 +0300 Subject: [PATCH] Fix pg_dump backup auth and servers API bearer timeouts. --- README.md | 3 ++ app.py | 109 ++++++++++++++++++++++++++++++++++++++++----- db/backup.py | 31 ++++++++++--- db/connection.py | 31 +++++++++++++ docker-compose.yml | 4 ++ 5 files changed, 163 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e63f62b..41259ac 100644 --- a/README.md +++ b/README.md @@ -419,6 +419,9 @@ TOKEN="awp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # List panel users curl -H "Authorization: Bearer $TOKEN" http://your-panel:5000/api/users +# List SSH servers (no credentials in response) +curl -H "Authorization: Bearer $TOKEN" http://your-panel:5000/api/servers + # Add a server curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"host":"1.2.3.4","username":"root","password":"...","name":"new-srv"}' \ diff --git a/app.py b/app.py index 87dfc49..9c5ee45 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.5" +CURRENT_VERSION = "v2.6.7" 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')) @@ -1354,6 +1354,30 @@ def _touch_api_token(token_entry: dict) -> bool: return True +def _schedule_api_token_touch(token_id: str) -> None: + """Persist API token last_used_at without blocking the request handler.""" + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + loop.create_task(_persist_api_token_touch(token_id)) + + +async def _persist_api_token_touch(token_id: str) -> None: + try: + async with DATA_LOCK: + data = await load_data_async() + entry = next( + (t for t in data.get('api_tokens', []) if t.get('id') == token_id), + None, + ) + if not entry or not _touch_api_token(entry): + return + await asyncio.to_thread(save_data, data) + except Exception as e: + logger.warning(f"Failed to touch API token last_used_at: {e}") + + def hash_password(password: str) -> str: salt = secrets.token_hex(16) h = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000) @@ -2944,18 +2968,83 @@ def _check_admin(request): resolved = _resolve_api_token(data, raw_token) if resolved: entry, token_user = resolved - # Best-effort last-used tracking; swallow write errors so a flaky - # disk never blocks an API call from succeeding. - try: - if _touch_api_token(entry): - save_data(data) - except Exception as e: - logger.warning(f"Failed to touch API token last_used_at: {e}") + if _touch_api_token(entry): + token_id = entry.get('id') + if token_id: + _schedule_api_token_touch(token_id) return token_user return None +async def _check_admin_async(request): + """Async variant for hot paths (server list/ping) — avoids blocking the loop.""" + user = get_current_user(request) + if user and user['role'] in ('admin', 'support'): + return user + + auth_header = request.headers.get('Authorization', '') + if auth_header.lower().startswith('bearer '): + raw_token = auth_header[7:].strip() + data = await load_data_async() + resolved = _resolve_api_token(data, raw_token) + if resolved: + entry, token_user = resolved + if _touch_api_token(entry): + token_id = entry.get('id') + if token_id: + _schedule_api_token_touch(token_id) + return token_user + + return None + + +def _public_vpn_server_view(server: dict, server_id: int) -> dict: + """Safe server list view for API consumers (no SSH secrets).""" + server_info = dict(server.get('server_info') or {}) + for key in list(server_info.keys()): + if key not in ('uname', 'ssl_domain', 'ssl_email', 'connect_domain'): + server_info.pop(key, None) + + protocols = {} + for key, info in (server.get('protocols') or {}).items(): + if not isinstance(info, dict): + continue + protocols[key] = { + 'installed': bool(info.get('installed')), + 'port': info.get('port'), + 'running': info.get('running'), + 'container_exists': info.get('container_exists'), + } + + return { + 'id': server_id, + 'name': server.get('name') or server.get('host') or '', + 'host': server.get('host') or '', + 'ssh_port': int(server.get('ssh_port') or 22), + 'username': server.get('username') or '', + 'auth': 'key' if server.get('private_key') else 'password', + 'has_password': bool(server.get('password')), + 'has_private_key': bool(server.get('private_key')), + 'server_info': server_info, + 'protocols': protocols, + } + + +@app.get('/api/servers', tags=["Servers"]) +async def api_list_servers(request: Request): + """List SSH servers in the panel inventory (credentials are never returned).""" + if not await _check_admin_async(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + data = await load_data_async() + servers = [ + _public_vpn_server_view(s, idx) + for idx, s in enumerate(data.get('servers', [])) + if isinstance(s, dict) + ] + return {'servers': servers, 'total': len(servers)} + + @app.post('/api/servers/add', tags=["Servers"]) async def api_add_server(request: Request, req: AddServerRequest): if not _check_admin(request): @@ -3166,9 +3255,9 @@ async def api_server_ping(request: Request, server_id: int): measures RTT, immediately closes. Runs on the asyncio loop so the page can issue many pings in parallel without blocking each other. """ - if not _check_admin(request): + if not await _check_admin_async(request): return JSONResponse({'error': 'Forbidden'}, status_code=403) - data = load_data() + data = await load_data_async() if server_id >= len(data['servers']): return JSONResponse({'error': 'Server not found'}, status_code=404) server = data['servers'][server_id] diff --git a/db/backup.py b/db/backup.py index f9195fd..66e8a51 100644 --- a/db/backup.py +++ b/db/backup.py @@ -3,15 +3,23 @@ from __future__ import annotations import logging +import os import subprocess from datetime import datetime, timezone -from .connection import get_database_url +from .connection import get_pg_connection_params from .store import invalidate_data_cache logger = logging.getLogger(__name__) +def _pg_cli_env(password: str) -> dict[str, str]: + env = os.environ.copy() + if password: + env['PGPASSWORD'] = password + return env + + def backup_filename() -> str: stamp = datetime.now(timezone.utc).strftime('%Y-%m-%d_%H%M%S') return f'amnezia_panel_backup_{stamp}.sql' @@ -19,11 +27,14 @@ def backup_filename() -> str: def export_database_sql() -> bytes: """Create a plain SQL dump of the panel PostgreSQL database.""" - url = get_database_url() + params = get_pg_connection_params() proc = subprocess.run( [ 'pg_dump', - '--dbname', url, + '-h', params['host'], + '-p', params['port'], + '-U', params['user'], + '-d', params['dbname'], '--no-owner', '--no-acl', '--clean', @@ -31,6 +42,7 @@ def export_database_sql() -> bytes: ], capture_output=True, check=False, + env=_pg_cli_env(params['password']), ) if proc.returncode != 0: err = proc.stderr.decode('utf-8', errors='replace').strip() @@ -44,12 +56,21 @@ 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() + params = get_pg_connection_params() proc = subprocess.run( - ['psql', '--dbname', url, '-v', 'ON_ERROR_STOP=1', '-q'], + [ + 'psql', + '-h', params['host'], + '-p', params['port'], + '-U', params['user'], + '-d', params['dbname'], + '-v', 'ON_ERROR_STOP=1', + '-q', + ], input=data, capture_output=True, check=False, + env=_pg_cli_env(params['password']), ) if proc.returncode != 0: err = proc.stderr.decode('utf-8', errors='replace').strip() diff --git a/db/connection.py b/db/connection.py index 41362f2..767504e 100644 --- a/db/connection.py +++ b/db/connection.py @@ -24,6 +24,37 @@ def get_database_url() -> str: return os.environ.get('DATABASE_URL', DEFAULT_DATABASE_URL).strip() +def get_pg_connection_params() -> dict[str, str]: + """Connection parameters for pg_dump/psql (same source as the app pool).""" + from psycopg.conninfo import conninfo_to_dict + + url = get_database_url() + scheme, _, rest = url.partition('://') + if scheme.startswith('postgresql'): + url = f'postgresql://{rest}' + + info = conninfo_to_dict(url) + params = { + 'host': str(info.get('host') or 'localhost'), + 'port': str(info.get('port') or '5432'), + 'user': str(info.get('user') or 'amnezia'), + 'password': str(info.get('password') or ''), + 'dbname': str(info.get('dbname') or 'amnezia_panel'), + } + + # Prefer discrete env vars when set (Dokploy / compose); avoids URL encoding issues. + if os.environ.get('POSTGRES_USER', '').strip(): + params['user'] = os.environ['POSTGRES_USER'].strip() + if os.environ.get('POSTGRES_PASSWORD', '').strip(): + params['password'] = os.environ['POSTGRES_PASSWORD'].strip() + if os.environ.get('POSTGRES_DB', '').strip(): + params['dbname'] = os.environ['POSTGRES_DB'].strip() + if os.environ.get('POSTGRES_PORT', '').strip(): + params['port'] = os.environ['POSTGRES_PORT'].strip() + + return params + + def get_pool(): global _pool if _pool is not None: diff --git a/docker-compose.yml b/docker-compose.yml index fb1a596..1fff9c3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,10 @@ services: - "${APP_PORT:-5000}:5000" environment: DATABASE_URL: postgresql://${POSTGRES_USER:-amnezia}:${POSTGRES_PASSWORD:-amnezia}@db:5432/${POSTGRES_DB:-amnezia_panel} + POSTGRES_USER: ${POSTGRES_USER:-amnezia} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-amnezia} + POSTGRES_DB: ${POSTGRES_DB:-amnezia_panel} + POSTGRES_PORT: "5432" SECRET_KEY: ${SECRET_KEY:-} APP_PORT: "5000" PORT: "5000"