Template
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:
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "زنده",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "В реальном времени",
|
||||||
|
|||||||
@@ -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": "实时",
|
||||||
|
|||||||
Reference in New Issue
Block a user