Use client connect domain in VPN configs and fix legacy JSON import to PostgreSQL.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-28 13:00:37 +03:00
co-authored by Cursor
parent 78e69d899a
commit 9b4ef0f9f0
9 changed files with 248 additions and 36 deletions
+62 -27
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.2" CURRENT_VERSION = "v2.6.4"
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'))
@@ -116,8 +116,10 @@ from db import ( # noqa: E402
ensure_db_ready, ensure_db_ready,
export_data_dict, export_data_dict,
export_database_sql, export_database_sql,
invalidate_data_cache,
load_data, load_data,
load_tunnel_state, load_tunnel_state,
normalize_import_data,
restore_database_sql, restore_database_sql,
save_data, save_data,
save_tunnel_state, 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): def get_current_user(request: Request, data: Optional[dict] = None):
user_id = request.session.get('user_id') user_id = request.session.get('user_id')
if not 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) base = protocol_base(protocol)
if base == 'telemt': if base == 'telemt':
user_data = (source_client or {}).get('userData') or {} user_data = (source_client or {}).get('userData') or {}
connect_host = get_server_connect_host(server)
return manager.add_client( return manager.add_client(
protocol, name, server['host'], port, protocol, name, connect_host, port,
telemt_quota=user_data.get('quota'), telemt_quota=user_data.get('quota'),
telemt_expiry=user_data.get('expiry'), telemt_expiry=user_data.get('expiry'),
secret=user_data.get('token'), secret=user_data.get('token'),
user_ad_tag=user_data.get('user_ad_tag'), user_ad_tag=user_data.get('user_ad_tag'),
max_tcp_conns=user_data.get('max_tcp_conns'), max_tcp_conns=user_data.get('max_tcp_conns'),
) )
connect_host = get_server_connect_host(server)
if base == 'wireguard': if base == 'wireguard':
return manager.add_client(name, server['host']) return manager.add_client(name, connect_host)
return manager.add_client(protocol, name, server['host'], port) return manager.add_client(protocol, name, connect_host, port)
def _move_connections_sync( 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']) manager = get_protocol_manager(ssh, c_req['protocol'])
if c_req['protocol'] == 'wireguard': 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: 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'): if res.get('client_id'):
new_conn = { new_conn = {
@@ -2093,6 +2111,7 @@ class AddServerRequest(BaseModel):
name: str = '' name: str = ''
ssl_domain: str = '' ssl_domain: str = ''
ssl_email: str = '' ssl_email: str = ''
connect_domain: str = ''
class EditServerRequest(BaseModel): class EditServerRequest(BaseModel):
@@ -2107,6 +2126,7 @@ class EditServerRequest(BaseModel):
private_key: Optional[str] = None private_key: Optional[str] = None
ssl_domain: Optional[str] = None ssl_domain: Optional[str] = None
ssl_email: Optional[str] = None ssl_email: Optional[str] = None
connect_domain: Optional[str] = None
class ReorderServersRequest(BaseModel): 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_domain = (req.ssl_domain or '').strip().lower()
ssl_email = (req.ssl_email or '').strip() ssl_email = (req.ssl_email or '').strip()
connect_domain = (req.connect_domain or '').strip().lower()
if ssl_domain: if ssl_domain:
server_info['ssl_domain'] = ssl_domain server_info['ssl_domain'] = ssl_domain
if ssl_email: if ssl_email:
server_info['ssl_email'] = ssl_email server_info['ssl_email'] = ssl_email
if connect_domain:
server_info['connect_domain'] = connect_domain
server['server_info'] = server_info server['server_info'] = server_info
data = load_data() data = load_data()
data['servers'].append(server) 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 {}) info = dict(server.get('server_info') or {})
if isinstance(server_info, dict): if isinstance(server_info, dict):
for k, v in server_info.items(): 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 info[k] = v
if req.ssl_domain is not None: if req.ssl_domain is not None:
domain = (req.ssl_domain or '').strip().lower() 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 info['ssl_email'] = email
else: else:
info.pop('ssl_email', None) 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 server['server_info'] = info
save_data(data) save_data(data)
return {'status': 'success', 'server_info': info} return {'status': 'success', 'server_info': info}
@@ -3954,7 +3983,7 @@ async def api_protocol_export_clients(request: Request, server_id: int, req: Pro
continue continue
try: try:
config = _manager_call( 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: except Exception as exc:
skipped.append(f'{client_name}: {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': if protocol_base(req.protocol) == 'telemt':
result = manager.add_client( 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_quota=req.telemt_quota,
telemt_max_ips=req.telemt_max_ips, telemt_max_ips=req.telemt_max_ips,
telemt_expiry=req.telemt_expiry, 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 max_tcp_conns=req.telemt_max_conns
) )
elif protocol_base(req.protocol) == 'wireguard': 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: 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() ssh.disconnect()
if result.get('config'): 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 = get_ssh(server)
ssh.connect() ssh.connect()
manager = get_protocol_manager(ssh, req.protocol) 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() ssh.disconnect()
vpn_link = generate_vpn_link(config) if config else '' vpn_link = generate_vpn_link(config) if config else ''
return {'config': config, 'vpn_link': vpn_link} 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) manager = get_protocol_manager(ssh, req.protocol)
if protocol_base(req.protocol) == 'telemt': if protocol_base(req.protocol) == 'telemt':
conn_result = manager.add_client( 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_quota=req.telemt_quota,
telemt_max_ips=req.telemt_max_ips, telemt_max_ips=req.telemt_max_ips,
telemt_expiry=req.telemt_expiry, 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 max_tcp_conns=req.telemt_max_conns
) )
else: 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() ssh.disconnect()
if conn_result.get('client_id'): 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 # Link existing client
config = await asyncio.to_thread( config = await asyncio.to_thread(
_manager_call, manager, 'get_client_config', _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} result = {'client_id': req.client_id, 'config': config}
elif protocol_base(req.protocol) == 'telemt': elif protocol_base(req.protocol) == 'telemt':
result = await asyncio.to_thread( 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_quota=req.telemt_quota,
telemt_max_ips=req.telemt_max_ips, telemt_max_ips=req.telemt_max_ips,
telemt_expiry=req.telemt_expiry, 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, max_tcp_conns=req.telemt_max_conns,
) )
elif protocol_base(req.protocol) == 'wireguard': 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: else:
result = await asyncio.to_thread( result = await asyncio.to_thread(
_manager_call, manager, 'add_client', _manager_call, manager, 'add_client',
req.protocol, req.name, server['host'], port, req.protocol, req.name, get_server_connect_host(server), port,
) )
finally: finally:
await asyncio.to_thread(ssh.disconnect) await asyncio.to_thread(ssh.disconnect)
@@ -5181,7 +5210,7 @@ async def api_share_config(token: str, connection_id: str, request: Request):
ssh.connect() ssh.connect()
# Use appropriate manager for the protocol # Use appropriate manager for the protocol
manager = get_protocol_manager(ssh, conn['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() ssh.disconnect()
vpn_link = generate_vpn_link(config) if config else '' vpn_link = generate_vpn_link(config) if config else ''
return {'config': config, 'vpn_link': vpn_link, 'expires_at': user.get('expiration_date')} 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 = get_ssh(server)
ssh.connect() ssh.connect()
manager = get_protocol_manager(ssh, conn['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() ssh.disconnect()
vpn_link = generate_vpn_link(config) if config else '' vpn_link = generate_vpn_link(config) if config else ''
return {'config': config, 'vpn_link': vpn_link, 'expires_at': holder.get('expiration_date')} 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: try:
manager = get_protocol_manager(ssh, protocol) manager = get_protocol_manager(ssh, protocol)
if protocol_base(protocol) == 'wireguard': 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: else:
result = await asyncio.to_thread( result = await asyncio.to_thread(
_manager_call, manager, 'add_client', _manager_call, manager, 'add_client',
protocol, name, server['host'], port, protocol, name, get_server_connect_host(server), port,
) )
finally: finally:
await asyncio.to_thread(ssh.disconnect) await asyncio.to_thread(ssh.disconnect)
@@ -5567,11 +5596,11 @@ async def _create_config_for_protocol(
try: try:
manager = get_protocol_manager(ssh, protocol) manager = get_protocol_manager(ssh, protocol)
if protocol_base(protocol) == 'wireguard': 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: else:
result = await asyncio.to_thread( result = await asyncio.to_thread(
_manager_call, manager, 'add_client', _manager_call, manager, 'add_client',
protocol, name, server['host'], port, protocol, name, get_server_connect_host(server), port,
) )
finally: finally:
await asyncio.to_thread(ssh.disconnect) await asyncio.to_thread(ssh.disconnect)
@@ -5942,7 +5971,7 @@ async def api_my_connection_config(request: Request, connection_id: str):
ssh.connect() ssh.connect()
# Use appropriate manager for the protocol (fixes Telemt/Xray not working for users) # Use appropriate manager for the protocol (fixes Telemt/Xray not working for users)
manager = get_protocol_manager(ssh, conn['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() ssh.disconnect()
vpn_link = generate_vpn_link(config) if config else '' vpn_link = generate_vpn_link(config) if config else ''
expires_at = None 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('invite_links', [])
backup_data.setdefault('settings', {}) backup_data.setdefault('settings', {})
async with DATA_LOCK: try:
save_data(backup_data) 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'} return {'status': 'success', 'format': 'json'}
try: try:
+4
View File
@@ -7,8 +7,10 @@ from .store import (
ensure_db_ready, ensure_db_ready,
export_data_dict, export_data_dict,
import_from_json_file, import_from_json_file,
invalidate_data_cache,
load_data, load_data,
load_tunnel_state, load_tunnel_state,
normalize_import_data,
save_data, save_data,
save_tunnel_state, save_tunnel_state,
update_tunnel_state, update_tunnel_state,
@@ -24,8 +26,10 @@ __all__ = [
'get_database_url', 'get_database_url',
'import_from_json_file', 'import_from_json_file',
'init_schema', 'init_schema',
'invalidate_data_cache',
'load_data', 'load_data',
'load_tunnel_state', 'load_tunnel_state',
'normalize_import_data',
'restore_database_sql', 'restore_database_sql',
'save_data', 'save_data',
'save_tunnel_state', 'save_tunnel_state',
+150 -7
View File
@@ -10,8 +10,10 @@ import copy
import json import json
import logging import logging
import os import os
import secrets
import shutil import shutil
import threading import threading
import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@@ -83,6 +85,147 @@ def _as_uuid(value: Any) -> UUID:
return UUID(str(value)) 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: def _merge_settings(raw: Optional[dict]) -> dict:
settings = json.loads(json.dumps(DEFAULT_SETTINGS)) settings = json.loads(json.dumps(DEFAULT_SETTINGS))
if not isinstance(raw, dict): if not isinstance(raw, dict):
@@ -293,6 +436,7 @@ def load_data() -> dict:
def save_data(data: dict) -> None: def save_data(data: dict) -> None:
"""Replace panel state in a single transaction (same semantics as rewriting data.json).""" """Replace panel state in a single transaction (same semantics as rewriting data.json)."""
global _DATA_CACHE global _DATA_CACHE
data = normalize_import_data(data)
init_schema() init_schema()
servers = data.get('servers') or [] servers = data.get('servers') or []
users = data.get('users') 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): if not isinstance(data, dict):
raise ValueError(f'Invalid data.json: expected object, got {type(data).__name__}') raise ValueError(f'Invalid data.json: expected object, got {type(data).__name__}')
data.setdefault('servers', []) data = normalize_import_data(data)
data.setdefault('users', [])
data.setdefault('user_connections', [])
data.setdefault('api_tokens', [])
data.setdefault('invite_links', [])
data.setdefault('settings', {})
save_data(data) save_data(data)
@@ -555,4 +694,8 @@ def ensure_db_ready(legacy_data_file: Optional[str] = None) -> None:
init_schema() init_schema()
if legacy_data_file and is_database_empty() and os.path.exists(legacy_data_file): 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) 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()
+20
View File
@@ -21,6 +21,7 @@
data-username="{{ server.username }}" data-auth="{{ 'key' if server.private_key else 'password' }}" 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-domain="{{ (server.server_info or {}).get('ssl_domain', '') }}"
data-ssl-email="{{ (server.server_info or {}).get('ssl_email', '') }}" data-ssl-email="{{ (server.server_info or {}).get('ssl_email', '') }}"
data-connect-domain="{{ (server.server_info or {}).get('connect_domain', '') }}"
draggable="true"> draggable="true">
<div class="server-meta"> <div class="server-meta">
<div class="server-icon">{{ icon('server') }}</div> <div class="server-icon">{{ icon('server') }}</div>
@@ -32,6 +33,10 @@
<span class="ping-ms text-muted" id="ping-ms-{{ loop.index0 }}"></span> <span class="ping-ms text-muted" id="ping-ms-{{ loop.index0 }}"></span>
</div> </div>
<div class="server-host">{{ server.host }}:{{ server.ssh_port }}</div> <div class="server-host">{{ server.host }}:{{ server.ssh_port }}</div>
{% set connect_domain = (server.server_info or {}).get('connect_domain') %}
{% if connect_domain and connect_domain != server.host %}
<div class="server-host text-muted" title="{{ _('server_connect_domain_hint') }}">→ {{ connect_domain }}</div>
{% endif %}
</div> </div>
</div> </div>
@@ -139,6 +144,12 @@
</div> </div>
</div> </div>
<div class="form-group" style="margin-top: var(--space-md)">
<label class="form-label">{{ _('server_connect_domain') }}</label>
<input class="form-input" type="text" id="editServerConnectDomain" placeholder="vpn.example.com">
<div class="form-hint">{{ _('server_connect_domain_hint') }}</div>
</div>
<div class="form-group" style="margin-top: var(--space-md)"> <div class="form-group" style="margin-top: var(--space-md)">
<label class="form-label">{{ _('server_ssl_domain') }}</label> <label class="form-label">{{ _('server_ssl_domain') }}</label>
<input class="form-input" type="text" id="editServerSslDomain" placeholder="vpn.example.com"> <input class="form-input" type="text" id="editServerSslDomain" placeholder="vpn.example.com">
@@ -214,6 +225,12 @@
</div> </div>
</div> </div>
<div class="form-group" style="margin-top: var(--space-md)">
<label class="form-label">{{ _('server_connect_domain') }}</label>
<input class="form-input" type="text" id="serverConnectDomain" placeholder="vpn.example.com">
<div class="form-hint">{{ _('server_connect_domain_hint') }}</div>
</div>
<div class="form-group" style="margin-top: var(--space-md)"> <div class="form-group" style="margin-top: var(--space-md)">
<label class="form-label">{{ _('server_ssl_domain') }}</label> <label class="form-label">{{ _('server_ssl_domain') }}</label>
<input class="form-input" type="text" id="serverSslDomain" placeholder="vpn.example.com"> <input class="form-input" type="text" id="serverSslDomain" placeholder="vpn.example.com">
@@ -268,6 +285,7 @@
private_key: document.getElementById('serverKey').value, private_key: document.getElementById('serverKey').value,
ssl_domain: document.getElementById('serverSslDomain').value.trim(), ssl_domain: document.getElementById('serverSslDomain').value.trim(),
ssl_email: document.getElementById('serverSslEmail').value.trim(), ssl_email: document.getElementById('serverSslEmail').value.trim(),
connect_domain: document.getElementById('serverConnectDomain').value.trim(),
}; };
const result = await apiCall('/api/servers/add', 'POST', data); const result = await apiCall('/api/servers/add', 'POST', data);
@@ -318,6 +336,7 @@
document.getElementById('editServerKey').value = ''; document.getElementById('editServerKey').value = '';
document.getElementById('editServerSslDomain').value = card.dataset.sslDomain || ''; document.getElementById('editServerSslDomain').value = card.dataset.sslDomain || '';
document.getElementById('editServerSslEmail').value = card.dataset.sslEmail || ''; document.getElementById('editServerSslEmail').value = card.dataset.sslEmail || '';
document.getElementById('editServerConnectDomain').value = card.dataset.connectDomain || '';
// Pre-select the tab matching the server's current auth method. // Pre-select the tab matching the server's current auth method.
const authIsKey = card.dataset.auth === 'key'; const authIsKey = card.dataset.auth === 'key';
@@ -349,6 +368,7 @@
private_key: document.getElementById('editServerKey').value || null, private_key: document.getElementById('editServerKey').value || null,
ssl_domain: document.getElementById('editServerSslDomain').value.trim(), ssl_domain: document.getElementById('editServerSslDomain').value.trim(),
ssl_email: document.getElementById('editServerSslEmail').value.trim(), ssl_email: document.getElementById('editServerSslEmail').value.trim(),
connect_domain: document.getElementById('editServerConnectDomain').value.trim(),
}; };
await apiCall(`/api/servers/${idx}/edit`, 'POST', body); await apiCall(`/api/servers/${idx}/edit`, 'POST', body);
showToast(_('server_saved'), 'success'); showToast(_('server_saved'), 'success');
+3 -1
View File
@@ -396,7 +396,9 @@
"naiveproxy_client_hint": "Stable build. Do NOT use v2rayN. Confirmed working in Karing. Other clients are untested.", "naiveproxy_client_hint": "Stable build. Do NOT use v2rayN. Confirmed working in Karing. Other clients are untested.",
"server_ssl_domain": "SSL domain (default)", "server_ssl_domain": "SSL domain (default)",
"server_ssl_email": "SSL email (default)", "server_ssl_email": "SSL email (default)",
"server_ssl_hint": "Used when installing Hysteria / NGINX on this server (Let\u0027s Encrypt).", "server_ssl_hint": "Used when installing Hysteria / NGINX on this server (Let's Encrypt).",
"server_connect_domain": "Client connection domain",
"server_connect_domain_hint": "Used in VPN configs instead of IP (Endpoint, vless://, etc.). When moving the server, update the DNS A-record — clients keep working.",
"container_logs": "Container logs", "container_logs": "Container logs",
"logs_btn": "📋 Logs", "logs_btn": "📋 Logs",
"logs_live": "Live", "logs_live": "Live",
+2
View File
@@ -387,6 +387,8 @@
"server_ssl_domain": "دامنه SSL (پیش‌فرض)", "server_ssl_domain": "دامنه SSL (پیش‌فرض)",
"server_ssl_email": "ایمیل SSL (پیش‌فرض)", "server_ssl_email": "ایمیل SSL (پیش‌فرض)",
"server_ssl_hint": "هنگام نصب Hysteria / NGINX روی این سرور استفاده می‌شود (Let\u0027s Encrypt).", "server_ssl_hint": "هنگام نصب Hysteria / NGINX روی این سرور استفاده می‌شود (Let\u0027s Encrypt).",
"server_connect_domain": "دامنه اتصال کلاینت",
"server_connect_domain_hint": "در پیکربندی VPN به‌جای IP استفاده می‌شود. هنگام انتقال سرور، رکورد DNS A را به‌روز کنید.",
"container_logs": "لاگ‌های کانتینر", "container_logs": "لاگ‌های کانتینر",
"logs_btn": "📋 Logs", "logs_btn": "📋 Logs",
"logs_live": "زنده", "logs_live": "زنده",
+2
View File
@@ -387,6 +387,8 @@
"server_ssl_domain": "Domaine SSL (par défaut)", "server_ssl_domain": "Domaine SSL (par défaut)",
"server_ssl_email": "Email SSL (par défaut)", "server_ssl_email": "Email SSL (par défaut)",
"server_ssl_hint": "Utilisé lors de l\u0027installation de Hysteria / NGINX sur ce serveur (Let\u0027s Encrypt).", "server_ssl_hint": "Utilisé lors de l\u0027installation de Hysteria / NGINX sur ce serveur (Let\u0027s Encrypt).",
"server_connect_domain": "Domaine de connexion client",
"server_connect_domain_hint": "Utilisé dans les configs VPN à la place de l\u0027IP. Lors d\u0027un déplacement du serveur, mettez à jour l\u0027enregistrement DNS A.",
"container_logs": "Logs du conteneur", "container_logs": "Logs du conteneur",
"logs_btn": "📋 Logs", "logs_btn": "📋 Logs",
"logs_live": "Temps réel", "logs_live": "Temps réel",
+3 -1
View File
@@ -396,7 +396,9 @@
"naiveproxy_client_hint": "Стабильная версия. НЕ используйте v2rayN. Точно работает в Karing. Остальные клиенты под вопросом.", "naiveproxy_client_hint": "Стабильная версия. НЕ используйте v2rayN. Точно работает в Karing. Остальные клиенты под вопросом.",
"server_ssl_domain": "SSL-домен (по умолчанию)", "server_ssl_domain": "SSL-домен (по умолчанию)",
"server_ssl_email": "SSL-email (по умолчанию)", "server_ssl_email": "SSL-email (по умолчанию)",
"server_ssl_hint": "Подставляется при установке Hysteria / NGINX на этом сервере (Let\u0027s Encrypt).", "server_ssl_hint": "Подставляется при установке Hysteria / NGINX на этом сервере (Let's Encrypt).",
"server_connect_domain": "Домен для подключения клиентов",
"server_connect_domain_hint": "Подставляется в конфиги VPN вместо IP (Endpoint, vless:// и т.д.). При переносе сервера достаточно обновить A-запись DNS — клиенты продолжат работать.",
"container_logs": "Логи контейнера", "container_logs": "Логи контейнера",
"logs_btn": "📋 Логи", "logs_btn": "📋 Логи",
"logs_live": "В реальном времени", "logs_live": "В реальном времени",
+2
View File
@@ -387,6 +387,8 @@
"server_ssl_domain": "SSL 域名(默认)", "server_ssl_domain": "SSL 域名(默认)",
"server_ssl_email": "SSL 邮箱(默认)", "server_ssl_email": "SSL 邮箱(默认)",
"server_ssl_hint": "安装 Hysteria / NGINX 时自动填入(Let\u0027s Encrypt)。", "server_ssl_hint": "安装 Hysteria / NGINX 时自动填入(Let\u0027s Encrypt)。",
"server_connect_domain": "客户端连接域名",
"server_connect_domain_hint": "在 VPN 配置中替代 IP 使用。迁移服务器时只需更新 DNS A 记录。",
"container_logs": "容器日志", "container_logs": "容器日志",
"logs_btn": "📋 日志", "logs_btn": "📋 日志",
"logs_live": "实时", "logs_live": "实时",