From f91ccf101376698077fdb3f8aec03177f7e27331 Mon Sep 17 00:00:00 2001 From: orohi Date: Sun, 26 Jul 2026 07:39:06 +0300 Subject: [PATCH] Release v1.6.0: multi-server 3x-ui with API inbounds. Co-authored-by: Cursor --- README.md | 18 +- app.py | 837 +++++++++++++++++++++++++++++++++++++--- managers/xui_api.py | 2 +- managers/xui_servers.py | 2 +- templates/guest.html | 1 + templates/invites.html | 106 ++++- templates/settings.html | 401 ++++++++++++++++++- templates/users.html | 75 +++- translations/en.json | 34 +- translations/fa.json | 34 +- translations/fr.json | 34 +- translations/ru.json | 34 +- translations/zh.json | 34 +- 13 files changed, 1523 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index a535012..f1cda49 100644 --- a/README.md +++ b/README.md @@ -219,16 +219,16 @@ GitHub Actions workflows in `.github/workflows/`: ## 📋 Fix / changelog (this fork) -Recent panel fixes and changes: - -* **Removed cascade (double VPN)** for now — the feature showed “active” while client traffic often had no working internet; will return after a solid redesign. -* **WG/AWG backups**: ZIP export of client `.conf` files + restore of protocol backups from the server UI (with loading feedback so the panel no longer freezes). -* **API performance**: in-memory data cache, faster server check/stats, heavy SSH work moved off the event loop. -* **Share / guest pages**: one-tap “Copy key” for configs. -* **User expiration**: optional countdown that starts on first config use (panel / share / guest / Telegram), with UTC-safe datetime comparison. -* **UI icons**: shared SVG icon system (emoji icons removed). +### v1.6.0 +* **3x-ui multi-panel**: register several 3x-ui servers in Settings; pick a panel and load VLESS inbounds over its API when creating users/invites (share link comes from 3x-ui). * **Docker / CI**: refreshed `Dockerfile` + compose, `.env.example`, CI checks, GHCR image workflow. -* **Removed for now**: Hysteria 2 and 3x-ui integration (kept out of the main panel path). +* **Cascade removed** for now (pending redesign — showed active while internet often did not work). +* **WG/AWG backups**: ZIP export of client `.conf` + restore with loading feedback. +* **API performance**: in-memory data cache, faster server check/stats. +* **Share / guest**: one-tap “Copy key”. +* **User expiration**: countdown can start on first config use; UTC-safe comparisons. +* **UI**: shared SVG icon system. +* **Removed earlier**: Hysteria 2. ## 🔧 Project Details diff --git a/app.py b/app.py index a474b68..694cd26 100644 --- a/app.py +++ b/app.py @@ -101,7 +101,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 = "v1.5.0" +CURRENT_VERSION = "v1.6.0" BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin')) TUNNEL_STATE_FILE = os.environ.get('TUNNEL_STATE_FILE', os.path.join(application_path, 'tunnels_state.json')) @@ -1001,7 +1001,7 @@ def _manager_call(manager, method, protocol, *args, **kwargs): # Protocols that own VPN clients (can list/link connections) -CLIENT_VPN_BASES = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard'} +CLIENT_VPN_BASES = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard', 'xui'} def generate_vpn_link(config_text): @@ -1087,10 +1087,18 @@ async def perform_delete_user(data: dict, user_id: str): user = next((u for u in data['users'] if u['id'] == user_id), None) if not user: return False - # Remove user's connections from servers + # Remove user's connections from servers / 3x-ui user_conns = [c for c in data.get('user_connections', []) if c['user_id'] == user_id] for uc in user_conns: try: + if protocol_base(uc.get('protocol', '')) == 'xui': + from managers.xui_api import xui_delete_client + await xui_delete_client( + data.get('settings', {}), + uc['client_id'], + panel_id=uc.get('xui_panel_id') or None, + ) + continue sid = uc['server_id'] if sid < len(data['servers']): server = data['servers'][sid] @@ -1117,6 +1125,15 @@ async def perform_toggle_user(data: dict, user_id: str, enable: bool) -> bool: user_conns = [c for c in data.get('user_connections', []) if c['user_id'] == user_id] for uc in user_conns: try: + if protocol_base(uc.get('protocol', '')) == 'xui': + from managers.xui_api import xui_toggle_client + await xui_toggle_client( + data.get('settings', {}), + uc['client_id'], + enable, + panel_id=uc.get('xui_panel_id') or None, + ) + continue sid = uc['server_id'] if sid >= len(data['servers']): continue @@ -1374,6 +1391,263 @@ async def sync_users_with_remnawave(data: dict): return 0, f"Error: {str(e)}" +def _xui_sanitize_username(email: str) -> str: + raw = (email or '').strip() + if not raw: + return '' + cleaned = re.sub(r'[^\w.\-@]+', '_', raw, flags=re.UNICODE) + return cleaned[:64] or raw[:64] + + +def _xui_parse_clients_from_inbounds(inbounds) -> list: + """Extract unique clients from classic 3x-ui inbounds list payload.""" + clients_by_email = {} + if not isinstance(inbounds, list): + return [] + for inbound in inbounds: + settings = inbound.get('settings') if isinstance(inbound, dict) else None + if isinstance(settings, str): + try: + settings = json.loads(settings) + except Exception: + settings = {} + if not isinstance(settings, dict): + continue + for client in settings.get('clients') or []: + if not isinstance(client, dict): + continue + email = (client.get('email') or '').strip() + if not email: + continue + prev = clients_by_email.get(email) + if prev and prev.get('enable') and not client.get('enable', True): + continue + clients_by_email[email] = client + return list(clients_by_email.values()) + + +async def _xui_fetch_clients(client: httpx.AsyncClient, base_url: str, headers: dict) -> list: + """Fetch clients from 3x-ui, supporting new and legacy API shapes.""" + base = base_url.rstrip('/') + for path in ('/panel/api/clients/list', '/panel/api/inbounds/list'): + try: + resp = await client.get(base + path, headers=headers) + except Exception: + continue + if resp.status_code != 200: + continue + try: + payload = resp.json() + except Exception: + continue + if not payload.get('success', True) and payload.get('success') is False: + continue + obj = payload.get('obj', payload.get('data')) + if path.endswith('/clients/list') and isinstance(obj, list): + by_email = {} + for c in obj: + if not isinstance(c, dict): + continue + email = (c.get('email') or '').strip() + if email: + by_email[email] = c + if by_email: + return list(by_email.values()) + if path.endswith('/inbounds/list'): + clients = _xui_parse_clients_from_inbounds(obj if isinstance(obj, list) else []) + if clients: + return clients + for path in ('/panel/api/inbounds/list', '/panel/inbound/list'): + try: + resp = await client.post(base + path, headers=headers) + except Exception: + continue + if resp.status_code != 200: + continue + try: + payload = resp.json() + except Exception: + continue + obj = payload.get('obj', payload.get('data')) + clients = _xui_parse_clients_from_inbounds(obj if isinstance(obj, list) else []) + if clients: + return clients + return [] + + +async def sync_users_with_xui(data: dict, *, force: bool = False): + """Import / update panel users from an existing 3x-ui instance.""" + from managers.xui_servers import ensure_xui_servers, list_xui_servers + panel_settings = data.setdefault('settings', {}) + ensure_xui_servers(panel_settings) + sync_settings = panel_settings.get('sync') or {} + + if not force and not sync_settings.get('xui_sync_users'): + servers_with_sync = [s for s in list_xui_servers(panel_settings) if s.get('sync_users')] + if not servers_with_sync: + return 0, "3x-ui synchronization is disabled in settings" + + url = (sync_settings.get('xui_url') or '').strip() + if not url: + primary = next((s for s in list_xui_servers(panel_settings) if s.get('enabled')), None) + if not primary: + primary = next(iter(list_xui_servers(panel_settings)), None) + url = (primary or {}).get('url') or '' + if not url: + return 0, "3x-ui URL not configured" + + api_token = (sync_settings.get('xui_api_token') or '').strip() + username = (sync_settings.get('xui_username') or '').strip() + password = sync_settings.get('xui_password') or '' + if not api_token and (not username or not password): + primary = next((s for s in list_xui_servers(panel_settings) if (s.get('url') or '').strip() == url), None) + if primary: + api_token = (primary.get('api_token') or '').strip() + username = (primary.get('username') or '').strip() + password = primary.get('password') or '' + + try: + async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: + headers = {'Accept': 'application/json'} + if api_token: + headers['Authorization'] = f'Bearer {api_token}' + else: + if not username or not password: + return 0, "Provide 3x-ui API token or username/password" + login_resp = await client.post( + url.rstrip('/') + '/login', + json={'username': username, 'password': password}, + headers=headers, + ) + if login_resp.status_code != 200: + return 0, f"3x-ui login failed: {login_resp.status_code} {login_resp.text[:200]}" + try: + login_body = login_resp.json() + except Exception: + login_body = {} + if login_body.get('success') is False: + return 0, f"3x-ui login failed: {login_body.get('msg', 'unknown error')}" + + xui_clients = await _xui_fetch_clients(client, url, headers) + if not xui_clients: + return 0, "No clients found in 3x-ui (check URL/credentials/API path)" + + xui_emails = {(c.get('email') or '').strip() for c in xui_clients if (c.get('email') or '').strip()} + + to_delete_ids = [] + for u in data['users']: + email = (u.get('xui_email') or '').strip() + if email and email not in xui_emails: + to_delete_ids.append(u['id']) + if to_delete_ids: + logger.info(f"Removing {len(to_delete_ids)} users deleted in 3x-ui") + await perform_mass_operations(delete_uids=to_delete_ids) + + synced_count = 0 + to_toggle = [] + to_create_conns = [] + + for xc in xui_clients: + email = (xc.get('email') or '').strip() + if not email: + continue + + data = load_data() + local_u = next((u for u in data['users'] if (u.get('xui_email') or '') == email), None) + uname = _xui_sanitize_username(email) + if not local_u and uname: + local_u = next((u for u in data['users'] if u['username'] == uname), None) + + is_active = bool(xc.get('enable', True)) + tg_id = xc.get('tgId') or xc.get('telegramId') or None + if tg_id is not None: + tg_id = str(tg_id) if str(tg_id).strip() else None + + traffic_limit = int(xc.get('totalGB') or 0) + expiry_ms = int(xc.get('expiryTime') or 0) + expiration_date = None + if expiry_ms > 0: + try: + expiration_date = datetime.fromtimestamp(expiry_ms / 1000.0).isoformat() + except Exception: + expiration_date = None + + if local_u: + local_u['username'] = uname or local_u['username'] + local_u['telegramId'] = tg_id + local_u['email'] = email if '@' in email else local_u.get('email') + local_u['description'] = xc.get('comment') or local_u.get('description') + local_u['xui_email'] = email + local_u['traffic_limit'] = traffic_limit + local_u['expiration_date'] = expiration_date + if local_u.get('enabled', True) != is_active: + to_toggle.append((local_u['id'], is_active)) + async with DATA_LOCK: + current = load_data() + idx = next((i for i, u in enumerate(current['users']) if u['id'] == local_u['id']), -1) + if idx != -1: + current['users'][idx] = local_u + save_data(current) + synced_count += 1 + else: + new_id = str(uuid.uuid4()) + final_name = uname or f"xui_{new_id[:8]}" + existing_names = {u['username'] for u in data['users']} + if final_name in existing_names: + final_name = f"{final_name}_{new_id[:6]}" + new_user = { + 'id': new_id, + 'username': final_name, + 'password_hash': '', + 'role': 'user', + 'telegramId': tg_id, + 'email': email if '@' in email else None, + 'description': xc.get('comment'), + 'enabled': is_active, + 'created_at': datetime.now().isoformat(), + 'remnawave_uuid': None, + 'xui_email': email, + 'traffic_limit': traffic_limit, + 'traffic_used': 0, + 'traffic_total': 0, + 'traffic_reset_strategy': 'never', + 'last_reset_at': datetime.now().isoformat(), + 'expiration_date': expiration_date, + 'expire_after_first_use': False, + 'expiration_days': 0, + 'share_enabled': False, + 'share_token': secrets.token_urlsafe(16), + 'share_password_hash': None, + } + async with DATA_LOCK: + current = load_data() + current['users'].append(new_user) + save_data(current) + + if sync_settings.get('xui_create_conns'): + sid = sync_settings.get('xui_server_id') + if sid is not None: + to_create_conns.append({ + 'user_id': new_id, + 'server_id': sid, + 'protocol': sync_settings.get('xui_protocol', 'xray'), + 'name': f"{final_name}_vpn", + }) + synced_count += 1 + + if to_toggle or to_create_conns: + logger.info( + f"Executing mass ops for 3x-ui sync: toggle={len(to_toggle)}, create={len(to_create_conns)}" + ) + await perform_mass_operations(toggle_uids=to_toggle, create_conns=to_create_conns) + + return synced_count, "Successfully synchronized with 3x-ui" + + except Exception as e: + logger.exception("3x-ui synchronization error") + return 0, f"Error: {str(e)}" + + # ======================== Pydantic Models ======================== class LoginRequest(BaseModel): @@ -1458,6 +1732,8 @@ class AddConnectionRequest(BaseModel): telemt_secret: Optional[str] = None telemt_ad_tag: Optional[str] = None telemt_max_conns: Optional[int] = None + xui_inbound_id: Optional[int] = None + xui_panel_id: Optional[str] = None class EditConnectionRequest(BaseModel): @@ -1535,6 +1811,29 @@ class SyncSettings(BaseModel): remnawave_create_conns: bool = False remnawave_server_id: int = 0 remnawave_protocol: str = 'awg' + xui_sync: bool = False + xui_url: str = '' + xui_username: str = '' + xui_password: str = '' + xui_api_token: str = '' + xui_sync_users: bool = False + xui_create_conns: bool = False + xui_server_id: int = 0 + xui_protocol: str = 'xray' + xui_inbound_id: int = 0 + xui_sub_url: str = '' + + +class XuiServerRequest(BaseModel): + name: str = '' + url: str = '' + sub_url: str = '' + api_token: str = '' + username: str = '' + password: str = '' + default_inbound_id: int = 0 + enabled: bool = True + sync_users: bool = False class CaptchaSettings(BaseModel): @@ -1562,8 +1861,10 @@ class GuestSettings(BaseModel): clear_password: bool = False user_id: str = '' allow_create: bool = False - create_protocol: str = 'awg' + create_protocol: str = 'xui' create_server_id: int = 0 + create_inbound_id: int = 0 + create_xui_panel_id: str = '' class UpdateUserRequest(BaseModel): @@ -1603,6 +1904,8 @@ class AddUserConnectionRequest(BaseModel): telemt_secret: Optional[str] = None telemt_ad_tag: Optional[str] = None telemt_max_conns: Optional[int] = None + xui_inbound_id: Optional[int] = None + xui_panel_id: Optional[str] = None class CreateApiTokenRequest(BaseModel): @@ -1626,8 +1929,10 @@ class InviteCreateRequest(BaseModel): name: str = 'Invite' max_uses: int = 1 # 0 = unlimited user_id: str = '' - protocol: str = 'awg' + protocol: str = 'xui' server_id: int = 0 + xui_inbound_id: int = 0 + xui_panel_id: str = '' password: Optional[str] = None duration_days: int = 0 # client lifetime after redeem; 0 = no expiry note: str = '' @@ -1640,6 +1945,8 @@ class InviteUpdateRequest(BaseModel): user_id: Optional[str] = None protocol: Optional[str] = None server_id: Optional[int] = None + xui_inbound_id: Optional[int] = None + xui_panel_id: Optional[str] = None password: Optional[str] = None clear_password: bool = False duration_days: Optional[int] = None @@ -1890,6 +2197,21 @@ async def periodic_background_tasks(): else: logger.info("Background Remnawave sync skipped (disabled in settings)") + # --- 3. 3X-UI SYNC --- + logger.info("Starting background 3x-ui sync...") + data = load_data() + sync_cfg = data.get('settings', {}).get('sync', {}) + from managers.xui_servers import ensure_xui_servers, list_xui_servers + ensure_xui_servers(data.setdefault('settings', {})) + xui_sync_enabled = bool(sync_cfg.get('xui_sync_users')) or any( + s.get('sync_users') for s in list_xui_servers(data.get('settings') or {}) + ) + if xui_sync_enabled: + count, msg = await sync_users_with_xui(data) + logger.info(f"Background 3x-ui sync finished: {count} users updated. {msg}") + else: + logger.info("Background 3x-ui sync skipped (disabled in settings)") + except Exception as e: logger.error(f"Error in periodic_background_tasks: {e}") @@ -1966,11 +2288,20 @@ async def users_page(request: Request): for u in users_list: u['connections_count'] = conn_counts.get(u['id'], 0) servers = data['servers'] + from managers.xui_servers import list_xui_servers, ensure_xui_servers, get_xui_server + ensure_xui_servers(data.setdefault('settings', {})) + xui_servers = list_xui_servers(data.get('settings') or {}) + primary = get_xui_server(data.get('settings') or {}, None) + xui_configured = bool(xui_servers) return tpl( request, 'users.html', users=users_list, servers=servers, current_user=user, + xui_configured=xui_configured, + xui_servers=xui_servers, + xui_inbound_id=int((primary or {}).get('default_inbound_id') or 0), + xui_default_panel_id=(primary or {}).get('id') or '', ) @@ -1983,6 +2314,9 @@ async def my_connections_page(request: Request): conns = [c for c in data.get('user_connections', []) if c['user_id'] == user['id']] # Enrich with server names for c in conns: + if protocol_base(c.get('protocol', '')) == 'xui': + c['server_name'] = '3x-ui' + continue sid = c.get('server_id', 0) if sid < len(data['servers']): c['server_name'] = data['servers'][sid].get('name', data['servers'][sid].get('host', '')) @@ -3355,6 +3689,40 @@ async def api_add_connection(request: Request, server_id: int, req: AddConnectio if server_id >= len(data['servers']): return JSONResponse({'error': 'Server not found'}, status_code=404) + if protocol_base(req.protocol) == 'xui': + from managers.xui_api import xui_create_vless_config + from managers.xui_servers import get_xui_server, ensure_xui_servers + ensure_xui_servers(data.setdefault('settings', {})) + panel = get_xui_server(data.get('settings') or {}, req.xui_panel_id) + panel_id = (panel or {}).get('id') or '' + inbound = req.xui_inbound_id or (panel or {}).get('default_inbound_id') or None + created = await xui_create_vless_config( + data.get('settings', {}), + name=req.name, + inbound_id=inbound, + panel_id=panel_id or None, + ) + result = {'client_id': created['client_id'], 'config': created.get('config') or ''} + if result.get('config'): + sub = created.get('subscription_url') or '' + result['vpn_link'] = sub if sub else ( + result['config'] if str(result['config']).startswith('http') else generate_vpn_link(result['config']) + ) + if req.user_id and result.get('client_id'): + conn = { + 'id': str(uuid.uuid4()), + 'user_id': req.user_id, + 'server_id': server_id, + 'protocol': 'xui', + 'client_id': result['client_id'], + 'name': req.name, + 'xui_panel_id': panel_id, + 'created_at': datetime.now().isoformat(), + } + data['user_connections'].append(conn) + save_data(data) + return result + server = data['servers'][server_id] proto_info = server.get('protocols', {}).get(req.protocol, {}) port = proto_info.get('port', '55424') @@ -3410,14 +3778,18 @@ async def api_remove_connection(request: Request, server_id: int, req: Connectio if not req.client_id: return JSONResponse({'error': 'Client ID is required'}, status_code=400) - if server_id >= len(data['servers']): - return JSONResponse({'error': 'Server not found'}, status_code=404) - server = data['servers'][server_id] - ssh = get_ssh(server) - ssh.connect() - manager = get_protocol_manager(ssh, req.protocol) - _manager_call(manager, 'remove_client', req.protocol, req.client_id) - ssh.disconnect() + if protocol_base(req.protocol) == 'xui': + from managers.xui_api import xui_delete_client + await xui_delete_client(data.get('settings', {}), req.client_id) + else: + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, req.protocol) + _manager_call(manager, 'remove_client', req.protocol, req.client_id) + ssh.disconnect() # Remove from user_connections data['user_connections'] = [ c for c in data.get('user_connections', []) @@ -3481,7 +3853,10 @@ async def api_get_connection_config(request: Request, server_id: int, req: Conne return JSONResponse({'error': 'Forbidden'}, status_code=403) if protocol_base(req.protocol) == 'xui': - return JSONResponse({'error': '3x-ui support removed'}, status_code=410) + from managers.xui_api import xui_get_config + config = await xui_get_config(data.get('settings', {}), req.client_id) + vpn_link = config if str(config).startswith('http') else (generate_vpn_link(config) if config else '') + return {'config': config, 'vpn_link': vpn_link} if server_id >= len(data['servers']): return JSONResponse({'error': 'Server not found'}, status_code=404) @@ -3509,7 +3884,9 @@ async def api_toggle_connection(request: Request, server_id: int, req: ToggleCon if not req.client_id: return JSONResponse({'error': 'Client ID is required'}, status_code=400) if protocol_base(req.protocol) == 'xui': - return JSONResponse({'error': '3x-ui support removed'}, status_code=410) + from managers.xui_api import xui_toggle_client + await xui_toggle_client(data.get('settings', {}), req.client_id, req.enable) + return {'status': 'success'} if server_id >= len(data['servers']): return JSONResponse({'error': 'Server not found'}, status_code=404) server = data['servers'][server_id] @@ -3575,7 +3952,7 @@ async def api_list_users(request: Request, search: str = '', page: int = 1, size 'has_share_password': bool(u.get('share_password_hash')), 'source': ( 'Remnawave' if u.get('remnawave_uuid') - else 'Local' + else ('3x-ui' if u.get('xui_email') else 'Local') ) }) return { @@ -3794,7 +4171,57 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo ) if protocol_base(req.protocol) == 'xui': - return JSONResponse({'error': '3x-ui support removed'}, status_code=410) + from managers.xui_servers import get_xui_server, ensure_xui_servers + ensure_xui_servers(data.setdefault('settings', {})) + panel = get_xui_server(data.get('settings') or {}, req.xui_panel_id) + if not panel: + return JSONResponse({'error': 'Add a 3x-ui server in Settings first'}, status_code=400) + panel_id = panel.get('id') or '' + if req.client_id: + from managers.xui_api import xui_get_config + config = await xui_get_config(data.get('settings', {}), req.client_id, panel_id=panel_id) + result = {'client_id': req.client_id, 'config': config, 'subscription_url': config if str(config).startswith('http') else ''} + else: + inbound = req.xui_inbound_id or panel.get('default_inbound_id') or None + if not inbound: + return JSONResponse({'error': 'Select a VLESS inbound from 3x-ui'}, status_code=400) + from managers.xui_api import xui_create_vless_config + created = await xui_create_vless_config( + data.get('settings', {}), + name=req.name or user.get('username') or 'user', + inbound_id=inbound, + panel_id=panel_id, + ) + result = { + 'client_id': created['client_id'], + 'config': created.get('config') or '', + 'subscription_url': created.get('subscription_url') or '', + } + sid = req.server_id if data['servers'] and req.server_id < len(data['servers']) else 0 + if result.get('client_id'): + conn = { + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'server_id': sid, + 'protocol': 'xui', + 'client_id': result['client_id'], + 'name': req.name, + 'xui_panel_id': panel_id, + 'created_at': datetime.now().isoformat(), + } + async with DATA_LOCK: + data = load_data() + data['user_connections'].append(conn) + save_data(data) + resp = {'status': 'success'} + if result.get('config'): + resp['config'] = result['config'] + sub = result.get('subscription_url') or '' + resp['subscription_url'] = sub + resp['vpn_link'] = sub if sub else ( + result['config'] if str(result['config']).startswith('http') else generate_vpn_link(result['config']) + ) + return resp if req.server_id >= len(data['servers']): return JSONResponse({'error': 'Server not found'}, status_code=404) @@ -3869,6 +4296,9 @@ async def api_get_user_connections(request: Request, user_id: str): data = load_data() conns = [c for c in data.get('user_connections', []) if c['user_id'] == user_id] for c in conns: + if protocol_base(c.get('protocol', '')) == 'xui': + c['server_name'] = '3x-ui' + continue sid = c.get('server_id', 0) if sid < len(data['servers']): c['server_name'] = data['servers'][sid].get('name', '') @@ -3885,6 +4315,9 @@ async def api_my_connections(request: Request): data = load_data() conns = [c for c in data.get('user_connections', []) if c['user_id'] == user['id']] for c in conns: + if protocol_base(c.get('protocol', '')) == 'xui': + c['server_name'] = '3x-ui' + continue sid = c.get('server_id', 0) if sid < len(data['servers']): c['server_name'] = data['servers'][sid].get('name', '') @@ -3991,7 +4424,19 @@ async def api_share_config(token: str, connection_id: str, request: Request): save_data(data) if protocol_base(conn.get('protocol', '')) == 'xui': - return JSONResponse({'error': '3x-ui support removed'}, status_code=410) + from managers.xui_api import xui_get_config + config = await xui_get_config( + data.get('settings', {}), + conn['client_id'], + panel_id=conn.get('xui_panel_id') or None, + ) + vpn_link = config if str(config).startswith('http') else (generate_vpn_link(config) if config else '') + return { + 'config': config, + 'vpn_link': vpn_link, + 'subscription_url': config if str(config).startswith('http') else '', + 'expires_at': user.get('expiration_date'), + } sid = conn['server_id'] server = data['servers'][sid] @@ -4022,8 +4467,10 @@ def _guest_settings(data: Optional[dict] = None) -> dict: 'password_hash': None, 'user_id': '', 'allow_create': False, - 'create_protocol': 'awg', + 'create_protocol': 'xui', 'create_server_id': 0, + 'create_inbound_id': 0, + 'create_xui_panel_id': '', } defaults.update(guest) return defaults @@ -4046,11 +4493,14 @@ def _resolve_guest(token: str, request: Request): def _enrich_guest_conn(c: dict, data: dict) -> dict: out = dict(c) - sid = out.get('server_id', 0) - if sid < len(data['servers']): - out['server_name'] = data['servers'][sid].get('name') or data['servers'][sid]['host'] + if protocol_base(out.get('protocol', '')) == 'xui': + out['server_name'] = '3x-ui' else: - out['server_name'] = 'Unknown' + sid = out.get('server_id', 0) + if sid < len(data['servers']): + out['server_name'] = data['servers'][sid].get('name') or data['servers'][sid]['host'] + else: + out['server_name'] = 'Unknown' return out @@ -4071,7 +4521,7 @@ async def guest_page(token: str, request: Request): need_password=need_password, token=token, allow_create=bool(guest.get('allow_create')), - create_protocol=guest.get('create_protocol') or 'awg', + create_protocol=guest.get('create_protocol') or 'xui', ) @@ -4100,7 +4550,7 @@ async def api_guest_connections(token: str, request: Request): return { 'connections': [], 'allow_create': bool(guest.get('allow_create')), - 'create_protocol': guest.get('create_protocol') or 'awg', + 'create_protocol': guest.get('create_protocol') or 'xui', } conns = [ _enrich_guest_conn(c, data) @@ -4110,7 +4560,7 @@ async def api_guest_connections(token: str, request: Request): return { 'connections': conns, 'allow_create': bool(guest.get('allow_create')), - 'create_protocol': guest.get('create_protocol') or 'awg', + 'create_protocol': guest.get('create_protocol') or 'xui', } @@ -4135,7 +4585,19 @@ async def api_guest_config(token: str, connection_id: str, request: Request): save_data(data) if protocol_base(conn.get('protocol', '')) == 'xui': - return JSONResponse({'error': '3x-ui support removed'}, status_code=410) + from managers.xui_api import xui_get_config + config = await xui_get_config( + data.get('settings', {}), + conn['client_id'], + panel_id=conn.get('xui_panel_id') or None, + ) + vpn_link = config if str(config).startswith('http') else (generate_vpn_link(config) if config else '') + return { + 'config': config, + 'vpn_link': vpn_link, + 'subscription_url': config if str(config).startswith('http') else '', + 'expires_at': holder.get('expiration_date'), + } sid = conn['server_id'] server = data['servers'][sid] @@ -4164,7 +4626,7 @@ async def api_guest_create(token: str, req: GuestCreateRequest, request: Request if not holder: return JSONResponse({'error': 'Guest user is not configured in settings'}, status_code=400) - protocol = guest.get('create_protocol') or 'awg' + protocol = guest.get('create_protocol') or 'xui' name = (req.name or 'Guest VPN').strip() or 'Guest VPN' # Unique-ish name to avoid collisions name = f"{name}_{secrets.token_hex(3)}" @@ -4174,25 +4636,48 @@ async def api_guest_create(token: str, req: GuestCreateRequest, request: Request if user_is_expired(holder): return JSONResponse({'error': 'Subscription expired'}, status_code=403) - sid = int(guest.get('create_server_id') or 0) - if sid >= len(data['servers']): - return JSONResponse({'error': 'Guest server not found'}, status_code=400) - server = data['servers'][sid] - proto_info = server.get('protocols', {}).get(protocol, {}) - port = proto_info.get('port', '55424') - ssh = get_ssh(server) - await asyncio.to_thread(ssh.connect) - try: - manager = get_protocol_manager(ssh, protocol) - if protocol_base(protocol) == 'wireguard': - result = await asyncio.to_thread(manager.add_client, name, server['host']) - else: - result = await asyncio.to_thread( - _manager_call, manager, 'add_client', - protocol, name, server['host'], port, - ) - finally: - await asyncio.to_thread(ssh.disconnect) + if protocol_base(protocol) == 'xui': + from managers.xui_api import xui_create_vless_config + from managers.xui_servers import get_xui_server, ensure_xui_servers + ensure_xui_servers(data.setdefault('settings', {})) + panel = get_xui_server(data.get('settings') or {}, guest.get('create_xui_panel_id') or None) + if not panel: + return JSONResponse({'error': 'Add a 3x-ui server in Settings first'}, status_code=400) + panel_id = panel.get('id') or '' + created = await xui_create_vless_config( + data.get('settings', {}), + name=name, + inbound_id=guest.get('create_inbound_id') or panel.get('default_inbound_id') or None, + panel_id=panel_id, + ) + result = { + 'client_id': created['client_id'], + 'config': created.get('config') or '', + 'subscription_url': created.get('subscription_url') or '', + 'xui_panel_id': panel_id, + } + sid = 0 + protocol = 'xui' + else: + sid = int(guest.get('create_server_id') or 0) + if sid >= len(data['servers']): + return JSONResponse({'error': 'Guest server not found'}, status_code=400) + server = data['servers'][sid] + proto_info = server.get('protocols', {}).get(protocol, {}) + port = proto_info.get('port', '55424') + ssh = get_ssh(server) + await asyncio.to_thread(ssh.connect) + try: + manager = get_protocol_manager(ssh, protocol) + if protocol_base(protocol) == 'wireguard': + result = await asyncio.to_thread(manager.add_client, name, server['host']) + else: + result = await asyncio.to_thread( + _manager_call, manager, 'add_client', + protocol, name, server['host'], port, + ) + finally: + await asyncio.to_thread(ssh.disconnect) if not result.get('client_id'): return JSONResponse({'error': 'Failed to create config'}, status_code=500) @@ -4206,6 +4691,8 @@ async def api_guest_create(token: str, req: GuestCreateRequest, request: Request 'name': name, 'created_at': datetime.now().isoformat(), } + if result.get('xui_panel_id'): + conn['xui_panel_id'] = result['xui_panel_id'] async with DATA_LOCK: data = load_data() data['user_connections'].append(conn) @@ -4280,16 +4767,60 @@ def _invite_auth_ok(link: dict, request: Request) -> bool: return bool(request.session.get(f"invite_auth_{link.get('token')}")) +def _expiry_ms_from_duration_days(duration_days: int) -> int: + """3x-ui expiryTime is unix ms; 0 means no expiry. Starts now (on redeem).""" + days = int(duration_days or 0) + if days <= 0: + return 0 + return int((datetime.now().timestamp() + days * 86400) * 1000) + + async def _create_config_for_protocol( data: dict, *, protocol: str, name: str, server_id: int = 0, + xui_inbound_id: Optional[int] = None, + xui_panel_id: Optional[str] = None, duration_days: int = 0, ) -> dict: """Create VPN client; returns {client_id, config, subscription_url, protocol, server_id}.""" - protocol = protocol or 'awg' + protocol = protocol or 'xui' + if protocol_base(protocol) == 'xui': + from managers.xui_api import xui_create_vless_config + from managers.xui_servers import get_xui_server, ensure_xui_servers + ensure_xui_servers(data.get('settings') or {}) + panel = get_xui_server(data.get('settings') or {}, xui_panel_id) + if not panel: + raise RuntimeError('Add a 3x-ui server in Settings first') + panel_id = panel.get('id') or '' + inbound = int(xui_inbound_id or 0) or int(panel.get('default_inbound_id') or 0) or None + if not inbound: + raise RuntimeError('Select a VLESS inbound from 3x-ui') + expiry_ms = _expiry_ms_from_duration_days(duration_days) + created = await xui_create_vless_config( + data.get('settings', {}), + name=name, + inbound_id=inbound, + expiry_time=expiry_ms, + panel_id=panel_id, + ) + return { + 'client_id': created['client_id'], + 'config': created.get('config') or '', + 'subscription_url': created.get('subscription_url') or '', + 'sub_id': created.get('sub_id') or '', + 'protocol': 'xui', + 'server_id': 0, + 'xui_panel_id': panel_id, + 'xui_inbound_id': int(created.get('inbound_id') or 0), + 'expires_at': ( + datetime.fromtimestamp(expiry_ms / 1000).isoformat() + if expiry_ms > 0 else None + ), + } + sid = int(server_id or 0) if sid >= len(data['servers']): raise RuntimeError('Server not found') @@ -4329,12 +4860,23 @@ async def invites_page(request: Request): if user['role'] not in ('admin', 'support'): return RedirectResponse(url='/my', status_code=302) data = load_data() + from managers.xui_servers import list_xui_servers, get_xui_server, ensure_xui_servers + ensure_xui_servers(data.setdefault('settings', {})) + xui_servers = list_xui_servers(data.get('settings') or {}) + primary = get_xui_server(data.get('settings') or {}, None) + any_sub = any((s.get('sub_url') or '').strip() for s in xui_servers) return tpl( request, 'invites.html', invites=[_invite_public_view(x) for x in data.get('invite_links', [])], users=data.get('users', []), servers=data.get('servers', []), + xui_servers=xui_servers, + xui_configured=bool(xui_servers), + xui_sub_url=(primary or {}).get('sub_url') or '', + xui_has_sub_url=any_sub, + xui_default_inbound=int((primary or {}).get('default_inbound_id') or 0), + xui_default_panel_id=(primary or {}).get('id') or '', ) @@ -4357,7 +4899,20 @@ async def api_create_invite(request: Request, req: InviteCreateRequest): data = load_data() if req.user_id and not any(u['id'] == req.user_id for u in data['users']): return JSONResponse({'error': 'User not found'}, status_code=400) - protocol = req.protocol or 'awg' + protocol = req.protocol or 'xui' + inbound_id = int(req.xui_inbound_id or 0) + panel_id = (req.xui_panel_id or '').strip() + if protocol_base(protocol) == 'xui': + from managers.xui_servers import get_xui_server, ensure_xui_servers + ensure_xui_servers(data.setdefault('settings', {})) + panel = get_xui_server(data.get('settings') or {}, panel_id or None) + if not panel: + return JSONResponse({'error': 'Add a 3x-ui server in Settings first'}, status_code=400) + panel_id = panel.get('id') or '' + if not inbound_id: + inbound_id = int(panel.get('default_inbound_id') or 0) + if not inbound_id: + return JSONResponse({'error': 'Select a VLESS inbound from 3x-ui'}, status_code=400) link = { 'id': str(uuid.uuid4()), 'name': (req.name or 'Invite').strip() or 'Invite', @@ -4368,9 +4923,8 @@ async def api_create_invite(request: Request, req: InviteCreateRequest): 'user_id': req.user_id or '', 'protocol': protocol, 'server_id': int(req.server_id or 0), - # xui_inbound_id / xui_panel_id kept for DB compat; no longer used - 'xui_inbound_id': 0, - 'xui_panel_id': '', + 'xui_inbound_id': inbound_id, + 'xui_panel_id': panel_id, 'password_hash': hash_password(req.password) if req.password else None, 'expires_at': None, 'duration_days': int(req.duration_days or 0), @@ -4405,6 +4959,10 @@ async def api_update_invite(request: Request, invite_id: str, req: InviteUpdateR link['protocol'] = req.protocol if req.server_id is not None: link['server_id'] = int(req.server_id) + if req.xui_inbound_id is not None: + link['xui_inbound_id'] = int(req.xui_inbound_id) + if req.xui_panel_id is not None: + link['xui_panel_id'] = (req.xui_panel_id or '').strip() if req.duration_days is not None: if req.duration_days < 0: return JSONResponse({'error': 'duration_days must be >= 0'}, status_code=400) @@ -4419,6 +4977,18 @@ async def api_update_invite(request: Request, invite_id: str, req: InviteUpdateR link['enabled'] = bool(req.enabled) if req.reset_used: link['used_count'] = 0 + if protocol_base(link.get('protocol') or 'xui') == 'xui': + from managers.xui_servers import get_xui_server, ensure_xui_servers + ensure_xui_servers(data.setdefault('settings', {})) + panel = get_xui_server(data.get('settings') or {}, link.get('xui_panel_id') or None) + if not panel: + return JSONResponse({'error': 'Add a 3x-ui server in Settings first'}, status_code=400) + if not link.get('xui_panel_id'): + link['xui_panel_id'] = panel.get('id') or '' + if not int(link.get('xui_inbound_id') or 0): + link['xui_inbound_id'] = int(panel.get('default_inbound_id') or 0) + if not int(link.get('xui_inbound_id') or 0): + return JSONResponse({'error': 'Select a VLESS inbound from 3x-ui'}, status_code=400) save_data(data) return {'status': 'success', 'invite': _invite_public_view(link)} @@ -4536,9 +5106,11 @@ async def api_invite_create_config(token: str, req: InviteRedeemRequest, request data = load_data() created = await _create_config_for_protocol( data, - protocol=link.get('protocol') or 'awg', + protocol=link.get('protocol') or 'xui', name=name, server_id=int(link.get('server_id') or 0), + xui_inbound_id=int(link.get('xui_inbound_id') or 0) or None, + xui_panel_id=link.get('xui_panel_id') or None, duration_days=int(link.get('duration_days') or 0), ) conn = { @@ -4548,6 +5120,7 @@ async def api_invite_create_config(token: str, req: InviteRedeemRequest, request 'protocol': created['protocol'], 'client_id': created['client_id'], 'name': name, + 'xui_panel_id': created.get('xui_panel_id') or link.get('xui_panel_id') or '', 'created_at': datetime.now().isoformat(), } async with DATA_LOCK: @@ -4606,7 +5179,23 @@ async def api_my_connection_config(request: Request, connection_id: str): return JSONResponse({'error': 'Connection not found'}, status_code=404) if protocol_base(conn.get('protocol', '')) == 'xui': - return JSONResponse({'error': '3x-ui support removed'}, status_code=410) + from managers.xui_api import xui_get_config + config = await xui_get_config( + data.get('settings', {}), + conn['client_id'], + panel_id=conn.get('xui_panel_id') or None, + ) + vpn_link = config if str(config).startswith('http') else (generate_vpn_link(config) if config else '') + expires_at = None + panel_user = next((u for u in data.get('users', []) if u['id'] == user['id']), None) + if panel_user: + expires_at = panel_user.get('expiration_date') + return { + 'config': config, + 'vpn_link': vpn_link, + 'subscription_url': config if str(config).startswith('http') else '', + 'expires_at': expires_at, + } sid = conn['server_id'] if sid >= len(data['servers']): @@ -4637,12 +5226,21 @@ async def settings_page(request: Request): if not user: return RedirectResponse('/login') data = load_data() + from managers.xui_servers import list_xui_servers, public_server_view, ensure_xui_servers + settings = data.setdefault('settings', {}) + before = json.dumps(settings.get('xui_servers') or [], sort_keys=True, default=str) + ensure_xui_servers(settings) + after = json.dumps(settings.get('xui_servers') or [], sort_keys=True, default=str) + if before != after: + save_data(data) + xui_servers = [public_server_view(s) for s in list_xui_servers(settings)] return tpl( request, 'settings.html', settings=data.get('settings', {}), servers=data.get('servers', []), users=data.get('users', []), + xui_servers=xui_servers, current_version=CURRENT_VERSION, ) @@ -4790,9 +5388,6 @@ async def save_settings(request: Request, payload: SaveSettingsRequest): guest['password_hash'] = existing_guest.get('password_hash') data['settings']['guest'] = guest - # 3x-ui support removed: drop any leftover server list from old installs - data['settings'].pop('xui_servers', None) - save_data(data) logger.info("Settings saved (including captcha and telegram)") @@ -4854,6 +5449,134 @@ async def api_sync_delete(request: Request): return {'status': 'success', 'count': len(to_delete_ids)} +@app.post('/api/settings/xui_sync_now', tags=["Settings"]) +async def api_xui_sync_now(request: Request): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + data = load_data() + + try: + body = await request.json() + except Exception: + body = {} + if isinstance(body, dict) and body: + sync_cfg = data.setdefault('settings', {}).setdefault('sync', {}) + for key in ( + 'xui_url', 'xui_username', 'xui_password', 'xui_api_token', + 'xui_create_conns', 'xui_server_id', 'xui_protocol', 'xui_sync_users', 'xui_sync', + 'xui_inbound_id', 'xui_sub_url', + ): + if key in body and body[key] is not None: + sync_cfg[key] = body[key] + async with DATA_LOCK: + current = load_data() + current.setdefault('settings', {}).setdefault('sync', {}).update(sync_cfg) + save_data(current) + data = current + + count, msg = await sync_users_with_xui(data, force=True) + ok = count > 0 or (isinstance(msg, str) and msg.startswith('Successfully')) + return { + 'status': 'success' if ok else 'error', + 'count': count, + 'message': msg, + } + + +@app.post('/api/settings/xui_sync_delete', tags=["Settings"]) +async def api_xui_sync_delete(request: Request): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + data = load_data() + to_delete_ids = [u['id'] for u in data['users'] if u.get('xui_email')] + if to_delete_ids: + await perform_mass_operations(delete_uids=to_delete_ids) + return {'status': 'success', 'count': len(to_delete_ids)} + + +@app.get('/api/settings/xui/inbounds', tags=["Settings"]) +async def api_xui_list_inbounds(request: Request, panel_id: str = ''): + """List VLESS inbounds from a 3x-ui panel (by panel_id or primary).""" + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + from managers.xui_api import xui_list_vless_inbounds + from managers.xui_servers import ensure_xui_servers, get_xui_server + ensure_xui_servers(data.setdefault('settings', {})) + panel = get_xui_server(data.get('settings') or {}, panel_id or None) + if not panel: + return JSONResponse({'error': 'No 3x-ui servers configured'}, status_code=400) + inbounds = await xui_list_vless_inbounds(data.get('settings', {}), panel_id=panel.get('id')) + return { + 'inbounds': inbounds, + 'panel_id': panel.get('id'), + 'panel_name': panel.get('name'), + 'sub_url': panel.get('sub_url') or '', + } + except Exception as e: + logger.exception("Error listing 3x-ui inbounds") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.get('/api/settings/xui/servers', tags=["Settings"]) +async def api_xui_list_servers(request: Request): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + data = load_data() + from managers.xui_servers import list_xui_servers, public_server_view, ensure_xui_servers + ensure_xui_servers(data.setdefault('settings', {})) + servers = [public_server_view(s) for s in list_xui_servers(data.get('settings') or {})] + return {'servers': servers} + + +@app.post('/api/settings/xui/servers', tags=["Settings"]) +async def api_xui_create_server(request: Request, req: XuiServerRequest): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + from managers.xui_servers import upsert_xui_server, public_server_view, ensure_xui_servers + ensure_xui_servers(data.setdefault('settings', {})) + server = upsert_xui_server(data['settings'], req.model_dump()) + save_data(data) + return {'status': 'success', 'server': public_server_view(server)} + except Exception as e: + logger.exception('Error creating 3x-ui server') + return JSONResponse({'error': str(e)}, status_code=400) + + +@app.put('/api/settings/xui/servers/{panel_id}', tags=["Settings"]) +async def api_xui_update_server(request: Request, panel_id: str, req: XuiServerRequest): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + from managers.xui_servers import upsert_xui_server, public_server_view, get_xui_server, ensure_xui_servers + ensure_xui_servers(data.setdefault('settings', {})) + if not get_xui_server(data.get('settings') or {}, panel_id): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = upsert_xui_server(data['settings'], req.model_dump(), panel_id=panel_id) + save_data(data) + return {'status': 'success', 'server': public_server_view(server)} + except Exception as e: + logger.exception('Error updating 3x-ui server') + return JSONResponse({'error': str(e)}, status_code=400) + + +@app.delete('/api/settings/xui/servers/{panel_id}', tags=["Settings"]) +async def api_xui_delete_server(request: Request, panel_id: str): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + data = load_data() + from managers.xui_servers import delete_xui_server, ensure_xui_servers + ensure_xui_servers(data.setdefault('settings', {})) + if not delete_xui_server(data['settings'], panel_id): + return JSONResponse({'error': 'Server not found'}, status_code=404) + save_data(data) + return {'status': 'success'} + + @app.get('/api/servers/{server_id}/{protocol}/clients', tags=["Connections"]) async def api_get_server_clients(request: Request, server_id: int, protocol: str): if not _check_admin(request): diff --git a/managers/xui_api.py b/managers/xui_api.py index 76036f2..145bc83 100644 --- a/managers/xui_api.py +++ b/managers/xui_api.py @@ -1,4 +1,4 @@ -"""3x-ui HTTP API client — create VLESS clients and fetch share links. +"""3x-ui HTTP API client - create VLESS clients and fetch share links. Works with modern panels (/panel/api/clients/*) and falls back to legacy /panel/api/inbounds/addClient when needed. diff --git a/managers/xui_servers.py b/managers/xui_servers.py index 8683e0d..7aa3e60 100644 --- a/managers/xui_servers.py +++ b/managers/xui_servers.py @@ -60,7 +60,7 @@ def ensure_xui_servers(settings: dict) -> list: servers = [migrated] settings['xui_servers'] = servers - # Mirror primary into legacy sync.* for older code paths (no get_xui_server — avoids recursion) + # Mirror primary into legacy sync.* for older code paths (no get_xui_server - avoids recursion) sync = settings.setdefault('sync', {}) primary = None for s in servers: diff --git a/templates/guest.html b/templates/guest.html index 2e80f62..750b1c0 100644 --- a/templates/guest.html +++ b/templates/guest.html @@ -104,6 +104,7 @@ if (p === 'awg2') return 'AmneziaWG 2.0'; if (p === 'awg_legacy') return 'AWG Legacy'; if (p === 'xray') return 'Xray'; + if (p === 'xui') return '3x-ui VLESS'; return (p || '').toUpperCase(); } diff --git a/templates/invites.html b/templates/invites.html index e2e5dcc..eca1cfd 100644 --- a/templates/invites.html +++ b/templates/invites.html @@ -18,6 +18,18 @@ + {% if not xui_has_sub_url and xui_configured %} +
+
+
⚠️
+
+
{{ _('invite_sub_url_missing_title') }}
+
{{ _('invite_sub_url_missing_hint') }}
+
+
+
+ {% endif %} +
{{ icon('link') }}
{{ _('invites_empty') }}
@@ -31,7 +43,12 @@
{{ inv.name }}
- {{ inv.protocol }} + {% if inv.protocol == 'xui' %} + 3x-ui · {% for s in xui_servers if s.id == inv.xui_panel_id %}{{ s.name }}{% else %}{{ _('xui_server_select_label') }}{% endfor %} + · inbound #{{ inv.xui_inbound_id or '—' }} + {% else %} + {{ inv.protocol }} + {% endif %}
{% if inv.available %} @@ -111,7 +128,8 @@
- + {% for s in servers %} {% set server_idx = loop.index0 %} {% for key, info in (s.protocols or {}).items() %} @@ -123,6 +141,21 @@
+
+ + +
{{ _('xui_server_select_hint') }}
+ + +
{{ _('xui_inbound_hint') }}
+
+
@@ -154,6 +187,9 @@