Fix pg_dump backup auth and servers API bearer timeouts.

This commit is contained in:
orohi
2026-07-29 11:04:19 +03:00
parent 53bffbd4fc
commit 3c95094fc8
5 changed files with 163 additions and 15 deletions
+3
View File
@@ -419,6 +419,9 @@ TOKEN="awp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# List panel users # List panel users
curl -H "Authorization: Bearer $TOKEN" http://your-panel:5000/api/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 # Add a server
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"host":"1.2.3.4","username":"root","password":"...","name":"new-srv"}' \ -d '{"host":"1.2.3.4","username":"root","password":"...","name":"new-srv"}' \
+99 -10
View File
@@ -103,7 +103,7 @@ else:
application_path = os.path.dirname(__file__) application_path = os.path.dirname(__file__)
DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export 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_REPO_URL = repo_url()
RELEASES_API_LATEST = api_latest_url() RELEASES_API_LATEST = api_latest_url()
BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin')) 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 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: def hash_password(password: str) -> str:
salt = secrets.token_hex(16) salt = secrets.token_hex(16)
h = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000) 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) resolved = _resolve_api_token(data, raw_token)
if resolved: if resolved:
entry, token_user = resolved entry, token_user = resolved
# Best-effort last-used tracking; swallow write errors so a flaky if _touch_api_token(entry):
# disk never blocks an API call from succeeding. token_id = entry.get('id')
try: if token_id:
if _touch_api_token(entry): _schedule_api_token_touch(token_id)
save_data(data)
except Exception as e:
logger.warning(f"Failed to touch API token last_used_at: {e}")
return token_user return token_user
return None 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"]) @app.post('/api/servers/add', tags=["Servers"])
async def api_add_server(request: Request, req: AddServerRequest): async def api_add_server(request: Request, req: AddServerRequest):
if not _check_admin(request): 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 measures RTT, immediately closes. Runs on the asyncio loop so the page
can issue many pings in parallel without blocking each other. 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) return JSONResponse({'error': 'Forbidden'}, status_code=403)
data = load_data() data = await load_data_async()
if server_id >= len(data['servers']): if server_id >= len(data['servers']):
return JSONResponse({'error': 'Server not found'}, status_code=404) return JSONResponse({'error': 'Server not found'}, status_code=404)
server = data['servers'][server_id] server = data['servers'][server_id]
+26 -5
View File
@@ -3,15 +3,23 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import subprocess import subprocess
from datetime import datetime, timezone from datetime import datetime, timezone
from .connection import get_database_url from .connection import get_pg_connection_params
from .store import invalidate_data_cache from .store import invalidate_data_cache
logger = logging.getLogger(__name__) 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: def backup_filename() -> str:
stamp = datetime.now(timezone.utc).strftime('%Y-%m-%d_%H%M%S') stamp = datetime.now(timezone.utc).strftime('%Y-%m-%d_%H%M%S')
return f'amnezia_panel_backup_{stamp}.sql' return f'amnezia_panel_backup_{stamp}.sql'
@@ -19,11 +27,14 @@ def backup_filename() -> str:
def export_database_sql() -> bytes: def export_database_sql() -> bytes:
"""Create a plain SQL dump of the panel PostgreSQL database.""" """Create a plain SQL dump of the panel PostgreSQL database."""
url = get_database_url() params = get_pg_connection_params()
proc = subprocess.run( proc = subprocess.run(
[ [
'pg_dump', 'pg_dump',
'--dbname', url, '-h', params['host'],
'-p', params['port'],
'-U', params['user'],
'-d', params['dbname'],
'--no-owner', '--no-owner',
'--no-acl', '--no-acl',
'--clean', '--clean',
@@ -31,6 +42,7 @@ def export_database_sql() -> bytes:
], ],
capture_output=True, capture_output=True,
check=False, check=False,
env=_pg_cli_env(params['password']),
) )
if proc.returncode != 0: if proc.returncode != 0:
err = proc.stderr.decode('utf-8', errors='replace').strip() 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.""" """Restore panel data from a plain SQL dump produced by pg_dump."""
if not data or not data.strip(): if not data or not data.strip():
raise ValueError('Empty backup file') raise ValueError('Empty backup file')
url = get_database_url() params = get_pg_connection_params()
proc = subprocess.run( 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, input=data,
capture_output=True, capture_output=True,
check=False, check=False,
env=_pg_cli_env(params['password']),
) )
if proc.returncode != 0: if proc.returncode != 0:
err = proc.stderr.decode('utf-8', errors='replace').strip() err = proc.stderr.decode('utf-8', errors='replace').strip()
+31
View File
@@ -24,6 +24,37 @@ def get_database_url() -> str:
return os.environ.get('DATABASE_URL', DEFAULT_DATABASE_URL).strip() 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(): def get_pool():
global _pool global _pool
if _pool is not None: if _pool is not None:
+4
View File
@@ -33,6 +33,10 @@ services:
- "${APP_PORT:-5000}:5000" - "${APP_PORT:-5000}:5000"
environment: environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-amnezia}:${POSTGRES_PASSWORD:-amnezia}@db:5432/${POSTGRES_DB:-amnezia_panel} 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:-} SECRET_KEY: ${SECRET_KEY:-}
APP_PORT: "5000" APP_PORT: "5000"
PORT: "5000" PORT: "5000"