diff --git a/app.py b/app.py index 4a532a0..b145e5e 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.2" +CURRENT_VERSION = "v2.6.4" 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')) @@ -116,8 +116,10 @@ from db import ( # noqa: E402 ensure_db_ready, export_data_dict, export_database_sql, + invalidate_data_cache, load_data, load_tunnel_state, + normalize_import_data, restore_database_sql, save_data, save_tunnel_state, @@ -197,6 +199,20 @@ def get_ssh(server): ) +def get_server_connect_host(server: dict) -> str: + """Hostname embedded in client VPN configs (Endpoint, vless://, etc.). + + SSH still uses server['host']; this value can be a DNS name so clients + keep working after the server IP changes — update the A-record only. + """ + info = server.get('server_info') or {} + for key in ('connect_domain', 'ssl_domain'): + val = (info.get(key) or '').strip() + if val: + return val.lower().rstrip('.') + return (server.get('host') or '').strip() + + def get_current_user(request: Request, data: Optional[dict] = None): user_id = request.session.get('user_id') if not user_id: @@ -1430,17 +1446,19 @@ def _create_remote_client(manager, protocol: str, server: dict, name: str, sourc base = protocol_base(protocol) if base == 'telemt': user_data = (source_client or {}).get('userData') or {} + connect_host = get_server_connect_host(server) return manager.add_client( - protocol, name, server['host'], port, + protocol, name, connect_host, port, telemt_quota=user_data.get('quota'), telemt_expiry=user_data.get('expiry'), secret=user_data.get('token'), user_ad_tag=user_data.get('user_ad_tag'), max_tcp_conns=user_data.get('max_tcp_conns'), ) + connect_host = get_server_connect_host(server) if base == 'wireguard': - return manager.add_client(name, server['host']) - return manager.add_client(protocol, name, server['host'], port) + return manager.add_client(name, connect_host) + return manager.add_client(protocol, name, connect_host, port) def _move_connections_sync( @@ -1645,9 +1663,9 @@ async def perform_mass_operations(delete_uids: List[str] = None, toggle_uids: Li manager = get_protocol_manager(ssh, c_req['protocol']) if c_req['protocol'] == 'wireguard': - res = await asyncio.to_thread(manager.add_client, c_req['name'], srv['host']) + res = await asyncio.to_thread(manager.add_client, c_req['name'], get_server_connect_host(srv)) else: - res = await asyncio.to_thread(_manager_call, manager, 'add_client', c_req['protocol'], c_req['name'], srv['host'], port) + res = await asyncio.to_thread(_manager_call, manager, 'add_client', c_req['protocol'], c_req['name'], get_server_connect_host(srv), port) if res.get('client_id'): new_conn = { @@ -2093,6 +2111,7 @@ class AddServerRequest(BaseModel): name: str = '' ssl_domain: str = '' ssl_email: str = '' + connect_domain: str = '' class EditServerRequest(BaseModel): @@ -2107,6 +2126,7 @@ class EditServerRequest(BaseModel): private_key: Optional[str] = None ssl_domain: Optional[str] = None ssl_email: Optional[str] = None + connect_domain: Optional[str] = None class ReorderServersRequest(BaseModel): @@ -2929,10 +2949,13 @@ async def api_add_server(request: Request, req: AddServerRequest): } ssl_domain = (req.ssl_domain or '').strip().lower() ssl_email = (req.ssl_email or '').strip() + connect_domain = (req.connect_domain or '').strip().lower() if ssl_domain: server_info['ssl_domain'] = ssl_domain if ssl_email: server_info['ssl_email'] = ssl_email + if connect_domain: + server_info['connect_domain'] = connect_domain server['server_info'] = server_info data = load_data() data['servers'].append(server) @@ -2998,7 +3021,7 @@ async def api_edit_server(request: Request, server_id: int, req: EditServerReque info = dict(server.get('server_info') or {}) if isinstance(server_info, dict): for k, v in server_info.items(): - if k not in ('ssl_domain', 'ssl_email'): + if k not in ('ssl_domain', 'ssl_email', 'connect_domain'): info[k] = v if req.ssl_domain is not None: domain = (req.ssl_domain or '').strip().lower() @@ -3012,6 +3035,12 @@ async def api_edit_server(request: Request, server_id: int, req: EditServerReque info['ssl_email'] = email else: info.pop('ssl_email', None) + if req.connect_domain is not None: + domain = (req.connect_domain or '').strip().lower() + if domain: + info['connect_domain'] = domain + else: + info.pop('connect_domain', None) server['server_info'] = info save_data(data) return {'status': 'success', 'server_info': info} @@ -3954,7 +3983,7 @@ async def api_protocol_export_clients(request: Request, server_id: int, req: Pro continue try: config = _manager_call( - manager, 'get_client_config', req.protocol, client_id, server['host'], port + manager, 'get_client_config', req.protocol, client_id, get_server_connect_host(server), port ) except Exception as exc: skipped.append(f'{client_name}: {exc}') @@ -4438,7 +4467,7 @@ async def api_add_connection(request: Request, server_id: int, req: AddConnectio if protocol_base(req.protocol) == 'telemt': result = manager.add_client( - req.protocol, req.name, server['host'], port, + req.protocol, req.name, get_server_connect_host(server), port, telemt_quota=req.telemt_quota, telemt_max_ips=req.telemt_max_ips, telemt_expiry=req.telemt_expiry, @@ -4447,9 +4476,9 @@ async def api_add_connection(request: Request, server_id: int, req: AddConnectio max_tcp_conns=req.telemt_max_conns ) elif protocol_base(req.protocol) == 'wireguard': - result = manager.add_client(req.name, server['host']) + result = manager.add_client(req.name, get_server_connect_host(server)) else: - result = manager.add_client(req.protocol, req.name, server['host'], port) + result = manager.add_client(req.protocol, req.name, get_server_connect_host(server), port) ssh.disconnect() if result.get('config'): @@ -4572,7 +4601,7 @@ async def api_get_connection_config(request: Request, server_id: int, req: Conne ssh = get_ssh(server) ssh.connect() manager = get_protocol_manager(ssh, req.protocol) - config = _manager_call(manager, 'get_client_config', req.protocol, req.client_id, server['host'], port) + config = _manager_call(manager, 'get_client_config', req.protocol, req.client_id, get_server_connect_host(server), port) ssh.disconnect() vpn_link = generate_vpn_link(config) if config else '' return {'config': config, 'vpn_link': vpn_link} @@ -4755,7 +4784,7 @@ async def api_add_user(request: Request, req: AddUserRequest): manager = get_protocol_manager(ssh, req.protocol) if protocol_base(req.protocol) == 'telemt': conn_result = manager.add_client( - req.protocol, conn_name, server['host'], port, + req.protocol, conn_name, get_server_connect_host(server), port, telemt_quota=req.telemt_quota, telemt_max_ips=req.telemt_max_ips, telemt_expiry=req.telemt_expiry, @@ -4764,7 +4793,7 @@ async def api_add_user(request: Request, req: AddUserRequest): max_tcp_conns=req.telemt_max_conns ) else: - conn_result = manager.add_client(req.protocol, conn_name, server['host'], port) + conn_result = manager.add_client(req.protocol, conn_name, get_server_connect_host(server), port) ssh.disconnect() if conn_result.get('client_id'): @@ -4972,12 +5001,12 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo # Link existing client config = await asyncio.to_thread( _manager_call, manager, 'get_client_config', - req.protocol, req.client_id, server['host'], port, + req.protocol, req.client_id, get_server_connect_host(server), port, ) result = {'client_id': req.client_id, 'config': config} elif protocol_base(req.protocol) == 'telemt': result = await asyncio.to_thread( - manager.add_client, req.protocol, req.name, server['host'], port, + manager.add_client, req.protocol, req.name, get_server_connect_host(server), port, telemt_quota=req.telemt_quota, telemt_max_ips=req.telemt_max_ips, telemt_expiry=req.telemt_expiry, @@ -4986,11 +5015,11 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo max_tcp_conns=req.telemt_max_conns, ) elif protocol_base(req.protocol) == 'wireguard': - result = await asyncio.to_thread(manager.add_client, req.name, server['host']) + result = await asyncio.to_thread(manager.add_client, req.name, get_server_connect_host(server)) else: result = await asyncio.to_thread( _manager_call, manager, 'add_client', - req.protocol, req.name, server['host'], port, + req.protocol, req.name, get_server_connect_host(server), port, ) finally: await asyncio.to_thread(ssh.disconnect) @@ -5181,7 +5210,7 @@ async def api_share_config(token: str, connection_id: str, request: Request): ssh.connect() # Use appropriate manager for the protocol manager = get_protocol_manager(ssh, conn['protocol']) - config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port) + config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], get_server_connect_host(server), port) ssh.disconnect() vpn_link = generate_vpn_link(config) if config else '' return {'config': config, 'vpn_link': vpn_link, 'expires_at': user.get('expiration_date')} @@ -5341,7 +5370,7 @@ async def api_guest_config(token: str, connection_id: str, request: Request): ssh = get_ssh(server) ssh.connect() manager = get_protocol_manager(ssh, conn['protocol']) - config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port) + config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], get_server_connect_host(server), port) ssh.disconnect() vpn_link = generate_vpn_link(config) if config else '' return {'config': config, 'vpn_link': vpn_link, 'expires_at': holder.get('expiration_date')} @@ -5405,11 +5434,11 @@ async def api_guest_create(token: str, req: GuestCreateRequest, request: Request try: manager = get_protocol_manager(ssh, protocol) if protocol_base(protocol) == 'wireguard': - result = await asyncio.to_thread(manager.add_client, name, server['host']) + result = await asyncio.to_thread(manager.add_client, name, get_server_connect_host(server)) else: result = await asyncio.to_thread( _manager_call, manager, 'add_client', - protocol, name, server['host'], port, + protocol, name, get_server_connect_host(server), port, ) finally: await asyncio.to_thread(ssh.disconnect) @@ -5567,11 +5596,11 @@ async def _create_config_for_protocol( try: manager = get_protocol_manager(ssh, protocol) if protocol_base(protocol) == 'wireguard': - result = await asyncio.to_thread(manager.add_client, name, server['host']) + result = await asyncio.to_thread(manager.add_client, name, get_server_connect_host(server)) else: result = await asyncio.to_thread( _manager_call, manager, 'add_client', - protocol, name, server['host'], port, + protocol, name, get_server_connect_host(server), port, ) finally: await asyncio.to_thread(ssh.disconnect) @@ -5942,7 +5971,7 @@ async def api_my_connection_config(request: Request, connection_id: str): ssh.connect() # Use appropriate manager for the protocol (fixes Telemt/Xray not working for users) manager = get_protocol_manager(ssh, conn['protocol']) - config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port) + config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], get_server_connect_host(server), port) ssh.disconnect() vpn_link = generate_vpn_link(config) if config else '' expires_at = None @@ -6654,8 +6683,14 @@ async def api_backup_restore(request: Request, file: UploadFile = File(...)): backup_data.setdefault('invite_links', []) backup_data.setdefault('settings', {}) - async with DATA_LOCK: - save_data(backup_data) + try: + backup_data = normalize_import_data(backup_data) + async with DATA_LOCK: + save_data(backup_data) + except Exception as e: + invalidate_data_cache() + logger.exception('JSON backup restore failed') + return JSONResponse({'error': f'Import failed: {e}'}, status_code=400) return {'status': 'success', 'format': 'json'} try: diff --git a/db/__init__.py b/db/__init__.py index 6e457ca..70c9d73 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -7,8 +7,10 @@ from .store import ( ensure_db_ready, export_data_dict, import_from_json_file, + invalidate_data_cache, load_data, load_tunnel_state, + normalize_import_data, save_data, save_tunnel_state, update_tunnel_state, @@ -24,8 +26,10 @@ __all__ = [ 'get_database_url', 'import_from_json_file', 'init_schema', + 'invalidate_data_cache', 'load_data', 'load_tunnel_state', + 'normalize_import_data', 'restore_database_sql', 'save_data', 'save_tunnel_state', diff --git a/db/store.py b/db/store.py index 9025dbc..e206f08 100644 --- a/db/store.py +++ b/db/store.py @@ -10,8 +10,10 @@ import copy import json import logging import os +import secrets import shutil import threading +import uuid from datetime import datetime, timezone from pathlib import Path from typing import Any, Optional @@ -83,6 +85,147 @@ def _as_uuid(value: Any) -> UUID: return UUID(str(value)) +def _is_valid_uuid(value: Any) -> bool: + if value is None: + return False + try: + UUID(str(value)) + return True + except (ValueError, AttributeError, TypeError): + return False + + +def _coerce_uuid(value: Any, *, default: Optional[str] = None) -> str: + if _is_valid_uuid(value): + return str(UUID(str(value))) + if default is not None: + return default + return str(uuid.uuid4()) + + +def _as_list(value: Any) -> list: + return list(value) if isinstance(value, list) else [] + + +def normalize_import_data(data: dict) -> dict: + """Prepare legacy data.json (or partial exports) for PostgreSQL constraints.""" + data = copy.deepcopy(data or {}) + data['servers'] = [s for s in _as_list(data.get('servers')) if isinstance(s, dict)] + data['users'] = [u for u in _as_list(data.get('users')) if isinstance(u, dict)] + data['user_connections'] = [c for c in _as_list(data.get('user_connections')) if isinstance(c, dict)] + data['api_tokens'] = [t for t in _as_list(data.get('api_tokens')) if isinstance(t, dict)] + data['invite_links'] = [l for l in _as_list(data.get('invite_links')) if isinstance(l, dict)] + if not isinstance(data.get('settings'), dict): + data['settings'] = {} + + id_map: dict[str, str] = {} + + def map_user_id(value: Any) -> Optional[str]: + if value is None or value == '': + return None + key = str(value) + if _is_valid_uuid(key): + resolved = str(UUID(key)) + id_map.setdefault(key, resolved) + return resolved + if key not in id_map: + id_map[key] = str(uuid.uuid4()) + return id_map[key] + + used_usernames: set[str] = set() + for index, user in enumerate(data['users']): + if not isinstance(user, dict): + continue + old_id = user.get('id') + if old_id is None or old_id == '': + user['id'] = str(uuid.uuid4()) + else: + key = str(old_id) + if _is_valid_uuid(key): + user['id'] = str(UUID(key)) + id_map.setdefault(key, user['id']) + else: + user['id'] = map_user_id(key) or str(uuid.uuid4()) + + username = (user.get('username') or '').strip() or f'user{index + 1}' + base = username + suffix = 2 + while username.lower() in used_usernames: + username = f'{base}_{suffix}' + suffix += 1 + user['username'] = username + used_usernames.add(username.lower()) + + user.setdefault('password_hash', '') + user.setdefault('role', 'user') + user.setdefault('enabled', True) + + valid_user_ids = {str(u['id']) for u in data['users'] if isinstance(u, dict) and u.get('id')} + + for server in data['servers']: + if not isinstance(server, dict): + continue + server['server_info'] = _as_dict(server.get('server_info')) + server['protocols'] = _as_dict(server.get('protocols')) + + for conn in data['user_connections']: + if not isinstance(conn, dict): + continue + conn['id'] = _coerce_uuid(conn.get('id')) + mapped_uid = map_user_id(conn.get('user_id')) + if mapped_uid: + conn['user_id'] = mapped_uid + + data['user_connections'] = [ + c for c in data['user_connections'] + if isinstance(c, dict) and str(c.get('user_id')) in valid_user_ids + ] + + seen_hashes: set[str] = set() + normalized_tokens = [] + for token in data['api_tokens']: + if not isinstance(token, dict): + continue + mapped_uid = map_user_id(token.get('user_id')) + if not mapped_uid or mapped_uid not in valid_user_ids: + continue + token['id'] = _coerce_uuid(token.get('id')) + token['user_id'] = mapped_uid + token_hash = (token.get('token_hash') or '').strip() + if not token_hash or token_hash in seen_hashes: + token_hash = secrets.token_hex(32) + token['token_hash'] = token_hash + seen_hashes.add(token_hash) + token.setdefault('token_prefix', (token_hash[:8] if token_hash else '')) + normalized_tokens.append(token) + data['api_tokens'] = normalized_tokens + + seen_invite_tokens: set[str] = set() + for link in data['invite_links']: + if not isinstance(link, dict): + continue + link['id'] = _coerce_uuid(link.get('id')) + mapped_uid = map_user_id(link.get('user_id')) + link['user_id'] = mapped_uid if mapped_uid in valid_user_ids else '' + token = (link.get('token') or '').strip() + if not token or token in seen_invite_tokens: + token = secrets.token_urlsafe(16) + while token in seen_invite_tokens: + token = secrets.token_urlsafe(16) + link['token'] = token + seen_invite_tokens.add(token) + + guest = data['settings'].get('guest') + if isinstance(guest, dict): + mapped_guest = map_user_id(guest.get('user_id')) + if mapped_guest in valid_user_ids: + guest['user_id'] = mapped_guest + elif guest.get('user_id'): + guest['user_id'] = '' + + return data + + def _merge_settings(raw: Optional[dict]) -> dict: settings = json.loads(json.dumps(DEFAULT_SETTINGS)) if not isinstance(raw, dict): @@ -293,6 +436,7 @@ def load_data() -> dict: def save_data(data: dict) -> None: """Replace panel state in a single transaction (same semantics as rewriting data.json).""" global _DATA_CACHE + data = normalize_import_data(data) init_schema() servers = data.get('servers') or [] users = data.get('users') or [] @@ -484,12 +628,7 @@ def import_from_json_file(path: str | os.PathLike, *, backup: bool = True) -> bo 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('invite_links', []) - data.setdefault('settings', {}) + data = normalize_import_data(data) save_data(data) @@ -555,4 +694,8 @@ def ensure_db_ready(legacy_data_file: Optional[str] = None) -> None: 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) + try: + import_from_json_file(legacy_data_file) + except Exception: + logger.exception('Legacy data.json import failed — panel will start with empty DB') + invalidate_data_cache() diff --git a/templates/index.html b/templates/index.html index 0e91e2c..79a68c1 100644 --- a/templates/index.html +++ b/templates/index.html @@ -21,6 +21,7 @@ data-username="{{ server.username }}" data-auth="{{ 'key' if server.private_key else 'password' }}" data-ssl-domain="{{ (server.server_info or {}).get('ssl_domain', '') }}" data-ssl-email="{{ (server.server_info or {}).get('ssl_email', '') }}" + data-connect-domain="{{ (server.server_info or {}).get('connect_domain', '') }}" draggable="true">