Template
Fix pg_dump backup auth and servers API bearer timeouts.
This commit is contained in:
@@ -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"}' \
|
||||||
|
|||||||
@@ -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
|
|
||||||
# disk never blocks an API call from succeeding.
|
|
||||||
try:
|
|
||||||
if _touch_api_token(entry):
|
if _touch_api_token(entry):
|
||||||
save_data(data)
|
token_id = entry.get('id')
|
||||||
except Exception as e:
|
if token_id:
|
||||||
logger.warning(f"Failed to touch API token last_used_at: {e}")
|
_schedule_api_token_touch(token_id)
|
||||||
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
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user