From 9b4ef0f9f05eac602ca11fb6eefe05c93ebf9895 Mon Sep 17 00:00:00 2001 From: orohi Date: Tue, 28 Jul 2026 13:00:37 +0300 Subject: [PATCH] Use client connect domain in VPN configs and fix legacy JSON import to PostgreSQL. Co-authored-by: Cursor --- app.py | 89 ++++++++++++++++-------- db/__init__.py | 4 ++ db/store.py | 157 +++++++++++++++++++++++++++++++++++++++++-- templates/index.html | 20 ++++++ translations/en.json | 4 +- translations/fa.json | 2 + translations/fr.json | 2 + translations/ru.json | 4 +- translations/zh.json | 2 + 9 files changed, 248 insertions(+), 36 deletions(-) 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">
{{ icon('server') }}
@@ -32,6 +33,10 @@
{{ server.host }}:{{ server.ssh_port }}
+ {% set connect_domain = (server.server_info or {}).get('connect_domain') %} + {% if connect_domain and connect_domain != server.host %} +
→ {{ connect_domain }}
+ {% endif %} @@ -139,6 +144,12 @@ +
+ + +
{{ _('server_connect_domain_hint') }}
+
+
@@ -214,6 +225,12 @@
+
+ + +
{{ _('server_connect_domain_hint') }}
+
+
@@ -268,6 +285,7 @@ private_key: document.getElementById('serverKey').value, ssl_domain: document.getElementById('serverSslDomain').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); @@ -318,6 +336,7 @@ document.getElementById('editServerKey').value = ''; document.getElementById('editServerSslDomain').value = card.dataset.sslDomain || ''; 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. const authIsKey = card.dataset.auth === 'key'; @@ -349,6 +368,7 @@ private_key: document.getElementById('editServerKey').value || null, ssl_domain: document.getElementById('editServerSslDomain').value.trim(), ssl_email: document.getElementById('editServerSslEmail').value.trim(), + connect_domain: document.getElementById('editServerConnectDomain').value.trim(), }; await apiCall(`/api/servers/${idx}/edit`, 'POST', body); showToast(_('server_saved'), 'success'); diff --git a/translations/en.json b/translations/en.json index d09838a..0296729 100644 --- a/translations/en.json +++ b/translations/en.json @@ -396,7 +396,9 @@ "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_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", "logs_btn": "📋 Logs", "logs_live": "Live", diff --git a/translations/fa.json b/translations/fa.json index bb20ede..4c26d6c 100644 --- a/translations/fa.json +++ b/translations/fa.json @@ -387,6 +387,8 @@ "server_ssl_domain": "دامنه SSL (پیش‌فرض)", "server_ssl_email": "ایمیل SSL (پیش‌فرض)", "server_ssl_hint": "هنگام نصب Hysteria / NGINX روی این سرور استفاده می‌شود (Let\u0027s Encrypt).", + "server_connect_domain": "دامنه اتصال کلاینت", + "server_connect_domain_hint": "در پیکربندی VPN به‌جای IP استفاده می‌شود. هنگام انتقال سرور، رکورد DNS A را به‌روز کنید.", "container_logs": "لاگ‌های کانتینر", "logs_btn": "📋 Logs", "logs_live": "زنده", diff --git a/translations/fr.json b/translations/fr.json index 604a612..e35a01c 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -387,6 +387,8 @@ "server_ssl_domain": "Domaine 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_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", "logs_btn": "📋 Logs", "logs_live": "Temps réel", diff --git a/translations/ru.json b/translations/ru.json index 452ebd4..f9844af 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -396,7 +396,9 @@ "naiveproxy_client_hint": "Стабильная версия. НЕ используйте v2rayN. Точно работает в Karing. Остальные клиенты под вопросом.", "server_ssl_domain": "SSL-домен (по умолчанию)", "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": "Логи контейнера", "logs_btn": "📋 Логи", "logs_live": "В реальном времени", diff --git a/translations/zh.json b/translations/zh.json index 453d333..9c0b4b7 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -387,6 +387,8 @@ "server_ssl_domain": "SSL 域名(默认)", "server_ssl_email": "SSL 邮箱(默认)", "server_ssl_hint": "安装 Hysteria / NGINX 时自动填入(Let\u0027s Encrypt)。", + "server_connect_domain": "客户端连接域名", + "server_connect_domain_hint": "在 VPN 配置中替代 IP 使用。迁移服务器时只需更新 DNS A 记录。", "container_logs": "容器日志", "logs_btn": "📋 日志", "logs_live": "实时",