Add Amnezia Web Panel source with PostgreSQL 17 storage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-26 00:02:18 +03:00
co-authored by Cursor
parent ead1c64dd1
commit 8b70d51c87
50 changed files with 21713 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
"""Database package for Amnezia Web Panel (PostgreSQL 17)."""
from .connection import close_pool, get_database_url, init_schema
from .store import (
clear_tunnel_state,
ensure_db_ready,
export_data_dict,
import_from_json_file,
load_data,
load_tunnel_state,
save_data,
save_tunnel_state,
update_tunnel_state,
)
__all__ = [
'clear_tunnel_state',
'close_pool',
'ensure_db_ready',
'export_data_dict',
'get_database_url',
'import_from_json_file',
'init_schema',
'load_data',
'load_tunnel_state',
'save_data',
'save_tunnel_state',
'update_tunnel_state',
]
+82
View File
@@ -0,0 +1,82 @@
"""PostgreSQL connection pool for Amnezia Web Panel."""
from __future__ import annotations
import logging
import os
import threading
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
logger = logging.getLogger(__name__)
DEFAULT_DATABASE_URL = 'postgresql://amnezia:amnezia@localhost:5432/amnezia_panel'
_pool = None
_pool_lock = threading.Lock()
_schema_ready = False
def get_database_url() -> str:
return os.environ.get('DATABASE_URL', DEFAULT_DATABASE_URL).strip()
def get_pool():
global _pool
if _pool is not None:
return _pool
with _pool_lock:
if _pool is not None:
return _pool
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
url = get_database_url()
logger.info('Connecting to PostgreSQL…')
_pool = ConnectionPool(
conninfo=url,
min_size=1,
max_size=10,
kwargs={
'autocommit': False,
'row_factory': dict_row,
},
open=True,
)
return _pool
def close_pool():
global _pool, _schema_ready
with _pool_lock:
if _pool is not None:
_pool.close()
_pool = None
_schema_ready = False
def init_schema():
"""Create tables if they do not exist."""
global _schema_ready
if _schema_ready:
return
schema_path = Path(__file__).with_name('schema.sql')
sql = schema_path.read_text(encoding='utf-8')
pool = get_pool()
with pool.connection() as conn:
with conn.cursor() as cur:
# psycopg3 executes one statement per execute()
for raw in sql.split(';'):
lines = [
ln for ln in raw.splitlines()
if ln.strip() and not ln.strip().startswith('--')
]
stmt = '\n'.join(lines).strip()
if stmt:
cur.execute(stmt)
conn.commit()
_schema_ready = True
logger.info('PostgreSQL schema ready')
+58
View File
@@ -0,0 +1,58 @@
"""One-shot CLI: import legacy data.json into PostgreSQL.
Usage:
python -m db.migrate_json [path/to/data.json]
"""
from __future__ import annotations
import logging
import os
import sys
from dotenv import load_dotenv
load_dotenv()
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
logger = logging.getLogger('migrate_json')
def main(argv: list[str] | None = None) -> int:
argv = list(argv if argv is not None else sys.argv[1:])
force = '--force' in argv
paths = [a for a in argv if not a.startswith('-')]
if getattr(sys, 'frozen', False):
app_path = os.path.dirname(sys.executable)
else:
app_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
data_file = paths[0] if paths else os.path.join(app_path, 'data.json')
from db.store import import_from_json_file, is_database_empty, load_data
if not os.path.exists(data_file):
logger.error('File not found: %s', data_file)
return 1
if not is_database_empty():
logger.warning('Database is not empty — import will overwrite panel tables.')
if not force:
logger.error('Re-run with --force to overwrite existing data.')
return 2
import_from_json_file(data_file, backup=True)
data = load_data()
logger.info(
'Done. servers=%s users=%s connections=%s tokens=%s',
len(data['servers']),
len(data['users']),
len(data['user_connections']),
len(data['api_tokens']),
)
return 0
if __name__ == '__main__':
raise SystemExit(main())
+75
View File
@@ -0,0 +1,75 @@
-- Amnezia Web Panel — PostgreSQL 17 schema
CREATE TABLE IF NOT EXISTS servers (
position INTEGER PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
host TEXT NOT NULL DEFAULT '',
ssh_port INTEGER NOT NULL DEFAULT 22,
username TEXT NOT NULL DEFAULT '',
password TEXT,
private_key TEXT,
server_info JSONB NOT NULL DEFAULT '{}'::jsonb,
protocols JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ,
telegram_id TEXT,
email TEXT,
description TEXT,
traffic_limit BIGINT NOT NULL DEFAULT 0,
traffic_used BIGINT NOT NULL DEFAULT 0,
traffic_total BIGINT NOT NULL DEFAULT 0,
traffic_reset_strategy TEXT NOT NULL DEFAULT 'never',
last_reset_at TIMESTAMPTZ,
expiration_date TIMESTAMPTZ,
remnawave_uuid TEXT,
share_enabled BOOLEAN NOT NULL DEFAULT FALSE,
share_token TEXT,
share_password_hash TEXT
);
CREATE TABLE IF NOT EXISTS user_connections (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
server_id INTEGER NOT NULL DEFAULT 0,
protocol TEXT NOT NULL DEFAULT '',
client_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ,
last_bytes BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_user_connections_user_id ON user_connections(user_id);
CREATE INDEX IF NOT EXISTS idx_user_connections_server_id ON user_connections(server_id);
CREATE TABLE IF NOT EXISTS api_tokens (
id UUID PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
token_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL DEFAULT '',
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
CREATE TABLE IF NOT EXISTS settings (
id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
data JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE TABLE IF NOT EXISTS tunnel_state (
provider TEXT PRIMARY KEY,
data JSONB NOT NULL DEFAULT '{}'::jsonb
);
INSERT INTO settings (id, data) VALUES (1, '{}'::jsonb)
ON CONFLICT (id) DO NOTHING;
+413
View File
@@ -0,0 +1,413 @@
"""Panel data store backed by PostgreSQL 17.
Preserves the same dict shape that used to live in data.json so existing
FastAPI handlers keep working without a full rewrite.
"""
from __future__ import annotations
import json
import logging
import os
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from uuid import UUID
from psycopg.types.json import Jsonb
from .connection import get_pool, init_schema
logger = logging.getLogger(__name__)
DEFAULT_SETTINGS = {
'appearance': {
'title': 'Amnezia',
'logo': '❤️',
'subtitle': 'Web Panel',
},
'sync': {
'remnawave_url': '',
'remnawave_api_key': '',
'remnawave_sync': False,
'remnawave_sync_users': False,
'remnawave_create_conns': False,
'remnawave_server_id': 0,
'remnawave_protocol': 'awg',
},
}
def _parse_ts(value: Any) -> Optional[datetime]:
if value is None or value == '':
return None
if isinstance(value, datetime):
return value
text = str(value).strip()
if not text:
return None
if text.endswith('Z'):
text = text[:-1] + '+00:00'
try:
return datetime.fromisoformat(text)
except ValueError:
return None
def _ts_iso(value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, datetime):
return value.isoformat()
return str(value)
def _as_uuid(value: Any) -> UUID:
return UUID(str(value))
def _merge_settings(raw: Optional[dict]) -> dict:
settings = json.loads(json.dumps(DEFAULT_SETTINGS))
if not isinstance(raw, dict):
return settings
for section, defaults in DEFAULT_SETTINGS.items():
incoming = raw.get(section)
if isinstance(incoming, dict) and isinstance(defaults, dict):
merged = dict(defaults)
merged.update(incoming)
settings[section] = merged
elif section in raw:
settings[section] = raw[section]
for key, value in raw.items():
if key not in settings:
settings[key] = value
return settings
def _row_to_server(row) -> dict:
return {
'name': row['name'] or '',
'host': row['host'] or '',
'ssh_port': int(row['ssh_port'] or 22),
'username': row['username'] or '',
'password': row['password'],
'private_key': row['private_key'],
'server_info': dict(row['server_info'] or {}),
'protocols': dict(row['protocols'] or {}),
}
def _row_to_user(row) -> dict:
return {
'id': str(row['id']),
'username': row['username'],
'password_hash': row['password_hash'] or '',
'role': row['role'] or 'user',
'enabled': bool(row['enabled']),
'created_at': _ts_iso(row['created_at']),
'telegramId': row['telegram_id'],
'email': row['email'],
'description': row['description'],
'traffic_limit': int(row['traffic_limit'] or 0),
'traffic_used': int(row['traffic_used'] or 0),
'traffic_total': int(row['traffic_total'] or 0),
'traffic_reset_strategy': row['traffic_reset_strategy'] or 'never',
'last_reset_at': _ts_iso(row['last_reset_at']),
'expiration_date': _ts_iso(row['expiration_date']),
'remnawave_uuid': row['remnawave_uuid'],
'share_enabled': bool(row['share_enabled']),
'share_token': row['share_token'],
'share_password_hash': row['share_password_hash'],
}
def _row_to_connection(row) -> dict:
return {
'id': str(row['id']),
'user_id': str(row['user_id']),
'server_id': int(row['server_id'] or 0),
'protocol': row['protocol'] or '',
'client_id': row['client_id'] or '',
'name': row['name'] or '',
'created_at': _ts_iso(row['created_at']),
'last_bytes': int(row['last_bytes'] or 0),
}
def _row_to_token(row) -> dict:
return {
'id': str(row['id']),
'name': row['name'] or '',
'token_hash': row['token_hash'] or '',
'token_prefix': row['token_prefix'] or '',
'user_id': str(row['user_id']),
'created_at': _ts_iso(row['created_at']),
'last_used_at': _ts_iso(row['last_used_at']),
}
def load_data() -> dict:
init_schema()
pool = get_pool()
with pool.connection() as conn:
with conn.cursor() as cur:
cur.execute(
'SELECT position, name, host, ssh_port, username, password, '
'private_key, server_info, protocols '
'FROM servers ORDER BY position ASC'
)
servers = [_row_to_server(r) for r in cur.fetchall()]
cur.execute(
'SELECT id, username, password_hash, role, enabled, created_at, '
'telegram_id, email, description, traffic_limit, traffic_used, '
'traffic_total, traffic_reset_strategy, last_reset_at, '
'expiration_date, remnawave_uuid, share_enabled, share_token, '
'share_password_hash FROM users ORDER BY created_at NULLS LAST, username'
)
users = [_row_to_user(r) for r in cur.fetchall()]
cur.execute(
'SELECT id, user_id, server_id, protocol, client_id, name, '
'created_at, last_bytes FROM user_connections '
'ORDER BY created_at NULLS LAST, id'
)
user_connections = [_row_to_connection(r) for r in cur.fetchall()]
cur.execute(
'SELECT id, name, token_hash, token_prefix, user_id, '
'created_at, last_used_at FROM api_tokens '
'ORDER BY created_at NULLS LAST, id'
)
api_tokens = [_row_to_token(r) for r in cur.fetchall()]
cur.execute('SELECT data FROM settings WHERE id = 1')
settings_row = cur.fetchone()
settings = _merge_settings(settings_row['data'] if settings_row else None)
return {
'servers': servers,
'users': users,
'user_connections': user_connections,
'api_tokens': api_tokens,
'settings': settings,
}
def save_data(data: dict) -> None:
"""Replace panel state in a single transaction (same semantics as rewriting data.json)."""
init_schema()
servers = data.get('servers') or []
users = data.get('users') or []
connections = data.get('user_connections') or []
tokens = data.get('api_tokens') or []
settings = _merge_settings(data.get('settings'))
# Keep only connections whose user still exists
user_ids = {str(u.get('id')) for u in users if u.get('id')}
connections = [c for c in connections if str(c.get('user_id')) in user_ids]
tokens = [t for t in tokens if str(t.get('user_id')) in user_ids]
pool = get_pool()
with pool.connection() as conn:
with conn.cursor() as cur:
# Order matters for FKs: children first on delete, parents first on insert
cur.execute('DELETE FROM api_tokens')
cur.execute('DELETE FROM user_connections')
cur.execute('DELETE FROM users')
cur.execute('DELETE FROM servers')
for pos, server in enumerate(servers):
cur.execute(
'INSERT INTO servers (position, name, host, ssh_port, username, '
'password, private_key, server_info, protocols) '
'VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)',
(
pos,
server.get('name') or '',
server.get('host') or '',
int(server.get('ssh_port') or 22),
server.get('username') or '',
server.get('password'),
server.get('private_key'),
Jsonb(server.get('server_info') or {}),
Jsonb(server.get('protocols') or {}),
),
)
for user in users:
cur.execute(
'INSERT INTO users ('
'id, username, password_hash, role, enabled, created_at, '
'telegram_id, email, description, traffic_limit, traffic_used, '
'traffic_total, traffic_reset_strategy, last_reset_at, '
'expiration_date, remnawave_uuid, share_enabled, share_token, '
'share_password_hash'
') VALUES ('
'%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s'
')',
(
_as_uuid(user['id']),
user.get('username') or '',
user.get('password_hash') or '',
user.get('role') or 'user',
bool(user.get('enabled', True)),
_parse_ts(user.get('created_at')),
str(user['telegramId']) if user.get('telegramId') is not None else None,
user.get('email'),
user.get('description'),
int(user.get('traffic_limit') or 0),
int(user.get('traffic_used') or 0),
int(user.get('traffic_total') or 0),
user.get('traffic_reset_strategy') or 'never',
_parse_ts(user.get('last_reset_at')),
_parse_ts(user.get('expiration_date')),
user.get('remnawave_uuid'),
bool(user.get('share_enabled', False)),
user.get('share_token'),
user.get('share_password_hash'),
),
)
for conn_row in connections:
cur.execute(
'INSERT INTO user_connections ('
'id, user_id, server_id, protocol, client_id, name, created_at, last_bytes'
') VALUES (%s, %s, %s, %s, %s, %s, %s, %s)',
(
_as_uuid(conn_row['id']),
_as_uuid(conn_row['user_id']),
int(conn_row.get('server_id') or 0),
conn_row.get('protocol') or '',
conn_row.get('client_id') or '',
conn_row.get('name') or '',
_parse_ts(conn_row.get('created_at')),
int(conn_row.get('last_bytes') or 0),
),
)
for token in tokens:
cur.execute(
'INSERT INTO api_tokens ('
'id, name, token_hash, token_prefix, user_id, created_at, last_used_at'
') VALUES (%s, %s, %s, %s, %s, %s, %s)',
(
_as_uuid(token['id']),
token.get('name') or '',
token.get('token_hash') or '',
token.get('token_prefix') or '',
_as_uuid(token['user_id']),
_parse_ts(token.get('created_at')),
_parse_ts(token.get('last_used_at')),
),
)
cur.execute(
'INSERT INTO settings (id, data) VALUES (1, %s) '
'ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data',
(Jsonb(settings),),
)
conn.commit()
def export_data_dict() -> dict:
"""Export current DB state as the legacy data.json document."""
return load_data()
def is_database_empty() -> bool:
init_schema()
pool = get_pool()
with pool.connection() as conn:
with conn.cursor() as cur:
cur.execute('SELECT COUNT(*) AS n FROM users')
users = cur.fetchone()['n']
cur.execute('SELECT COUNT(*) AS n FROM servers')
servers = cur.fetchone()['n']
return users == 0 and servers == 0
def import_from_json_file(path: str | os.PathLike, *, backup: bool = True) -> bool:
"""Import legacy data.json into Postgres. Returns True if import ran."""
path = Path(path)
if not path.exists():
return False
with path.open('r', encoding='utf-8') as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError(f'Invalid data.json: expected object, got {type(data).__name__}')
data.setdefault('servers', [])
data.setdefault('users', [])
data.setdefault('user_connections', [])
data.setdefault('api_tokens', [])
data.setdefault('settings', {})
save_data(data)
if backup:
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
backup_path = path.with_name(f'data.json.migrated.{stamp}.bak')
try:
shutil.copy2(path, backup_path)
logger.info('Backed up legacy data.json to %s', backup_path)
except OSError as e:
logger.warning('Could not backup data.json: %s', e)
logger.info(
'Imported data.json → PostgreSQL (%s servers, %s users, %s connections)',
len(data.get('servers', [])),
len(data.get('users', [])),
len(data.get('user_connections', [])),
)
return True
def load_tunnel_state() -> dict:
init_schema()
pool = get_pool()
with pool.connection() as conn:
with conn.cursor() as cur:
cur.execute('SELECT provider, data FROM tunnel_state')
rows = cur.fetchall()
return {row['provider']: dict(row['data'] or {}) for row in rows}
def save_tunnel_state(state: dict) -> None:
init_schema()
pool = get_pool()
with pool.connection() as conn:
with conn.cursor() as cur:
cur.execute('DELETE FROM tunnel_state')
for provider, payload in (state or {}).items():
cur.execute(
'INSERT INTO tunnel_state (provider, data) VALUES (%s, %s)',
(str(provider), Jsonb(payload or {})),
)
conn.commit()
def update_tunnel_state(provider: str, **updates) -> None:
state = load_tunnel_state()
provider_state = state.get(provider, {})
provider_state.update(updates)
state[provider] = provider_state
save_tunnel_state(state)
def clear_tunnel_state(provider: str) -> None:
state = load_tunnel_state()
if provider in state:
state.pop(provider)
save_tunnel_state(state)
def ensure_db_ready(legacy_data_file: Optional[str] = None) -> None:
"""Init schema and one-shot import from data.json when DB is empty."""
init_schema()
if legacy_data_file and is_database_empty() and os.path.exists(legacy_data_file):
logger.info('Empty database — importing legacy %s', legacy_data_file)
import_from_json_file(legacy_data_file)