diff --git a/app.py b/app.py index 4c241bb..058ffd5 100644 --- a/app.py +++ b/app.py @@ -964,7 +964,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', 'xui'} +CLIENT_VPN_BASES = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard'} def generate_vpn_link(config_text): @@ -1050,14 +1050,10 @@ 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 / 3x-ui + # Remove user's connections from servers 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']) - continue sid = uc['server_id'] if sid < len(data['servers']): server = data['servers'][sid] @@ -1084,10 +1080,6 @@ 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) - continue sid = uc['server_id'] if sid >= len(data['servers']): continue @@ -1345,259 +1337,6 @@ 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 '' - # Keep readable usernames; strip characters that break our forms - 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 - # Prefer enabled=true if the same email appears in multiple inbounds - 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('/') - - # Newer panels: dedicated clients list - 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): - # Deduplicate by email - 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 - - # Legacy: POST /panel/api/inbounds/list (and /panel/inbound/list) - 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. - - force=True: used by the manual "Sync now" button (ignores xui_sync_users flag). - Background jobs keep force=False and only run when xui_sync_users is enabled. - """ - settings = data.get('settings', {}).get('sync', {}) - if not force and not settings.get('xui_sync_users'): - return 0, "3x-ui synchronization is disabled in settings" - - url = (settings.get('xui_url') or '').strip() - if not url: - return 0, "3x-ui URL not configured" - - api_token = (settings.get('xui_api_token') or '').strip() - username = (settings.get('xui_username') or '').strip() - password = settings.get('xui_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()} - - # 1. Delete local users that were synced from 3x-ui but no longer exist there - 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 - - # totalGB in 3x-ui is usually bytes despite the name - 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()) - # Avoid username collisions - 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, - '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 settings.get('xui_create_conns'): - sid = settings.get('xui_server_id') - if sid is not None: - to_create_conns.append({ - 'user_id': new_id, - 'server_id': sid, - 'protocol': 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)}" - - def get_current_user(request: Request): user_id = request.session.get('user_id') if not user_id: @@ -1712,8 +1451,6 @@ 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): @@ -1789,29 +1526,6 @@ 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): @@ -1839,10 +1553,8 @@ class GuestSettings(BaseModel): clear_password: bool = False user_id: str = '' allow_create: bool = False - create_protocol: str = 'xui' + create_protocol: str = 'awg' create_server_id: int = 0 - create_inbound_id: int = 0 - create_xui_panel_id: str = '' class UpdateUserRequest(BaseModel): @@ -1880,8 +1592,6 @@ 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): @@ -1905,10 +1615,8 @@ class InviteCreateRequest(BaseModel): name: str = 'Invite' max_uses: int = 1 # 0 = unlimited user_id: str = '' - protocol: str = 'xui' + protocol: str = 'awg' 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 = '' @@ -1921,8 +1629,6 @@ 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 @@ -2167,15 +1873,6 @@ 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() - if data.get('settings', {}).get('sync', {}).get('xui_sync_users'): - 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}") @@ -2251,19 +1948,10 @@ async def users_page(request: Request): for u in users_list: u['connections_count'] = sum(1 for c in conns if c['user_id'] == u['id']) 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, - 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 '', ) @@ -2276,9 +1964,6 @@ 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', '')) @@ -3449,30 +3134,6 @@ 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 - created = await xui_create_vless_config( - data.get('settings', {}), - name=req.name, - inbound_id=req.xui_inbound_id, - ) - result = {'client_id': created['client_id'], 'config': created.get('config') or ''} - if result.get('config'): - result['vpn_link'] = 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, - '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') @@ -3528,18 +3189,14 @@ 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 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() + 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', []) @@ -3603,10 +3260,7 @@ 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': - from managers.xui_api import xui_get_config - config = await xui_get_config(data.get('settings', {}), req.client_id) - vpn_link = generate_vpn_link(config) if config else '' - return {'config': config, 'vpn_link': vpn_link} + return JSONResponse({'error': '3x-ui support removed'}, status_code=410) if server_id >= len(data['servers']): return JSONResponse({'error': 'Server not found'}, status_code=404) @@ -3634,9 +3288,7 @@ 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': - from managers.xui_api import xui_toggle_client - await xui_toggle_client(data.get('settings', {}), req.client_id, req.enable) - return {'status': 'success'} + return JSONResponse({'error': '3x-ui support removed'}, status_code=410) if server_id >= len(data['servers']): return JSONResponse({'error': 'Server not found'}, status_code=404) server = data['servers'][server_id] @@ -3699,7 +3351,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 ('3x-ui' if u.get('xui_email') else 'Local') + else 'Local' ) }) return { @@ -3892,60 +3544,8 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo status_code=400, ) - # 3x-ui VLESS — no SSH; uses selected 3x-ui panel credentials if protocol_base(req.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 {}, 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 - # Don't wrap subscription HTTPS URLs into vpn:// - resp['vpn_link'] = sub if sub else ( - result['config'] if str(result['config']).startswith('http') else generate_vpn_link(result['config']) - ) - return resp + return JSONResponse({'error': '3x-ui support removed'}, status_code=410) if req.server_id >= len(data['servers']): return JSONResponse({'error': 'Server not found'}, status_code=404) @@ -4020,9 +3620,6 @@ 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', '') @@ -4039,9 +3636,6 @@ 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', '') @@ -4142,18 +3736,7 @@ async def api_share_config(token: str, connection_id: str, request: Request): try: if protocol_base(conn.get('protocol', '')) == 'xui': - 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 '', - } + return JSONResponse({'error': '3x-ui support removed'}, status_code=410) sid = conn['server_id'] server = data['servers'][sid] @@ -4184,10 +3767,8 @@ def _guest_settings(data: Optional[dict] = None) -> dict: 'password_hash': None, 'user_id': '', 'allow_create': False, - 'create_protocol': 'xui', + 'create_protocol': 'awg', 'create_server_id': 0, - 'create_inbound_id': 0, - 'create_xui_panel_id': '', } defaults.update(guest) return defaults @@ -4210,14 +3791,11 @@ def _resolve_guest(token: str, request: Request): def _enrich_guest_conn(c: dict, data: dict) -> dict: out = dict(c) - if protocol_base(out.get('protocol', '')) == 'xui': - out['server_name'] = '3x-ui' + 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: - 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' + out['server_name'] = 'Unknown' return out @@ -4238,7 +3816,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 'xui', + create_protocol=guest.get('create_protocol') or 'awg', ) @@ -4267,7 +3845,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 'xui', + 'create_protocol': guest.get('create_protocol') or 'awg', } conns = [ _enrich_guest_conn(c, data) @@ -4277,7 +3855,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 'xui', + 'create_protocol': guest.get('create_protocol') or 'awg', } @@ -4296,18 +3874,7 @@ async def api_guest_config(token: str, connection_id: str, request: Request): return JSONResponse({'error': 'Not found'}, status_code=404) try: if protocol_base(conn.get('protocol', '')) == 'xui': - 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 '', - } + return JSONResponse({'error': '3x-ui support removed'}, status_code=410) sid = conn['server_id'] server = data['servers'][sid] @@ -4336,54 +3903,31 @@ 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 'xui' + protocol = guest.get('create_protocol') or 'awg' name = (req.name or 'Guest VPN').strip() or 'Guest VPN' # Unique-ish name to avoid collisions name = f"{name}_{secrets.token_hex(3)}" try: - 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) + 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) @@ -4395,7 +3939,6 @@ async def api_guest_create(token: str, req: GuestCreateRequest, request: Request 'protocol': protocol, 'client_id': result['client_id'], 'name': name, - 'xui_panel_id': result.get('xui_panel_id') or '', 'created_at': datetime.now().isoformat(), } async with DATA_LOCK: @@ -4450,10 +3993,8 @@ def _invite_public_view(link: dict) -> dict: 'expired': False, 'exhausted': exhausted, 'has_password': bool(link.get('password_hash')), - 'protocol': link.get('protocol') or 'xui', + 'protocol': link.get('protocol') or 'awg', 'server_id': int(link.get('server_id') or 0), - 'xui_inbound_id': int(link.get('xui_inbound_id') or 0), - 'xui_panel_id': link.get('xui_panel_id') or '', 'duration_days': duration_days, 'user_id': link.get('user_id') or '', 'note': link.get('note') or '', @@ -4472,60 +4013,16 @@ 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 '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 - ), - } - + protocol = protocol or 'awg' sid = int(server_id or 0) if sid >= len(data['servers']): raise RuntimeError('Server not found') @@ -4565,23 +4062,12 @@ 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 '', ) @@ -4604,20 +4090,7 @@ 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 '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) + protocol = req.protocol or 'awg' link = { 'id': str(uuid.uuid4()), 'name': (req.name or 'Invite').strip() or 'Invite', @@ -4628,8 +4101,9 @@ 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': inbound_id, - 'xui_panel_id': panel_id, + # xui_inbound_id / xui_panel_id kept for DB compat; no longer used + 'xui_inbound_id': 0, + 'xui_panel_id': '', 'password_hash': hash_password(req.password) if req.password else None, 'expires_at': None, 'duration_days': int(req.duration_days or 0), @@ -4664,10 +4138,6 @@ 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) @@ -4682,18 +4152,6 @@ 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)} @@ -4811,11 +4269,9 @@ 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 'xui', + protocol=link.get('protocol') or 'awg', 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 = { @@ -4825,7 +4281,6 @@ 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: @@ -4877,18 +4332,7 @@ 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': - 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 '', - } + return JSONResponse({'error': '3x-ui support removed'}, status_code=410) sid = conn['server_id'] if sid >= len(data['servers']): @@ -4915,21 +4359,12 @@ 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, ) @@ -5077,6 +4512,9 @@ 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)") @@ -5138,136 +4576,6 @@ 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() - - # Allow syncing with credentials from the request body (form may not be saved yet) - 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] - # Persist so background sync / next page load keep the values - 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/db/schema.sql b/db/schema.sql index cb6c2ae..d08f635 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -81,7 +81,7 @@ CREATE TABLE IF NOT EXISTS invite_links ( max_uses INTEGER NOT NULL DEFAULT 1, used_count INTEGER NOT NULL DEFAULT 0, user_id UUID, - protocol TEXT NOT NULL DEFAULT 'xui', + protocol TEXT NOT NULL DEFAULT 'awg', server_id INTEGER NOT NULL DEFAULT 0, xui_inbound_id INTEGER NOT NULL DEFAULT 0, xui_panel_id TEXT NOT NULL DEFAULT '', diff --git a/db/store.py b/db/store.py index 966100d..bbea24d 100644 --- a/db/store.py +++ b/db/store.py @@ -35,29 +35,15 @@ DEFAULT_SETTINGS = { 'remnawave_create_conns': False, 'remnawave_server_id': 0, 'remnawave_protocol': 'awg', - 'xui_sync': False, - 'xui_url': '', - 'xui_username': '', - 'xui_password': '', - 'xui_api_token': '', - 'xui_sync_users': False, - 'xui_create_conns': False, - 'xui_server_id': 0, - 'xui_protocol': 'xray', - 'xui_inbound_id': 0, - 'xui_sub_url': '', }, - 'xui_servers': [], 'guest': { 'enabled': False, 'token': '', 'password_hash': None, 'user_id': '', 'allow_create': False, - 'create_protocol': 'xui', + 'create_protocol': 'awg', 'create_server_id': 0, - 'create_inbound_id': 0, - 'create_xui_panel_id': '', }, } @@ -105,12 +91,6 @@ def _merge_settings(raw: Optional[dict]) -> dict: for key, value in raw.items(): if key not in settings: settings[key] = value - # Multi 3x-ui panels: migrate legacy sync.xui_* if needed - try: - from managers.xui_servers import ensure_xui_servers - ensure_xui_servers(settings) - except Exception: - settings.setdefault('xui_servers', []) return settings @@ -214,7 +194,7 @@ def _row_to_invite(row) -> dict: 'max_uses': int(row['max_uses'] or 0), 'used_count': int(row['used_count'] or 0), 'user_id': str(row['user_id']) if row['user_id'] else '', - 'protocol': row['protocol'] or 'xui', + 'protocol': row['protocol'] or 'awg', 'server_id': int(row['server_id'] or 0), 'xui_inbound_id': int(row['xui_inbound_id'] or 0), 'xui_panel_id': (row.get('xui_panel_id') or '') if hasattr(row, 'get') else '', @@ -415,7 +395,7 @@ def save_data(data: dict) -> None: int(link.get('max_uses') or 0), int(link.get('used_count') or 0), _as_uuid(uid) if uid else None, - link.get('protocol') or 'xui', + link.get('protocol') or 'awg', int(link.get('server_id') or 0), int(link.get('xui_inbound_id') or 0), link.get('xui_panel_id') or '', diff --git a/managers/xui_api.py b/managers/xui_api.py deleted file mode 100644 index 76036f2..0000000 --- a/managers/xui_api.py +++ /dev/null @@ -1,561 +0,0 @@ -"""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. -""" - -from __future__ import annotations - -import json -import logging -import secrets -import string -import uuid -from typing import Any, Optional -from urllib.parse import quote - -import httpx - -logger = logging.getLogger(__name__) - - -def _new_sub_id(length: int = 16) -> str: - alphabet = string.ascii_lowercase + string.digits - return ''.join(secrets.choice(alphabet) for _ in range(length)) - - -def _settings_creds(settings: dict) -> dict: - sync = (settings or {}).get('sync') or {} - return { - 'url': (sync.get('xui_url') or '').strip().rstrip('/'), - 'api_token': (sync.get('xui_api_token') or '').strip(), - 'username': (sync.get('xui_username') or '').strip(), - 'password': sync.get('xui_password') or '', - 'inbound_id': sync.get('xui_inbound_id'), - 'sub_url': (sync.get('xui_sub_url') or '').strip().rstrip('/'), - } - - -def build_subscription_url(settings: dict, sub_id: str) -> str: - """Build public subscription URL from panel settings + client subId.""" - sub_id = (sub_id or '').strip() - if not sub_id: - return '' - base = _settings_creds(settings).get('sub_url') or '' - if not base: - return '' - return f"{base}/{sub_id}" - - -def _resolve_settings(settings: dict, panel_id: Optional[str] = None) -> dict: - """Scope settings to a specific xui panel when panel_id / multi-server registry is used.""" - try: - from managers.xui_servers import resolve_panel_settings, ensure_xui_servers - ensure_xui_servers(settings) - return resolve_panel_settings(settings, panel_id) - except Exception: - return settings - - -class XuiApiError(RuntimeError): - pass - - -class XuiApi: - def __init__(self, base_url: str, *, api_token: str = '', username: str = '', password: str = ''): - self.base_url = (base_url or '').rstrip('/') - if not self.base_url: - raise XuiApiError('3x-ui URL is not configured') - self.api_token = (api_token or '').strip() - self.username = (username or '').strip() - self.password = password or '' - self._client: Optional[httpx.AsyncClient] = None - - @classmethod - def from_panel_settings(cls, settings: dict) -> 'XuiApi': - c = _settings_creds(settings) - return cls(c['url'], api_token=c['api_token'], username=c['username'], password=c['password']) - - async def __aenter__(self) -> 'XuiApi': - headers = {'Accept': 'application/json'} - if self.api_token: - headers['Authorization'] = f'Bearer {self.api_token}' - self._client = httpx.AsyncClient( - base_url=self.base_url, - timeout=30.0, - follow_redirects=True, - headers=headers, - ) - if not self.api_token: - await self._login() - return self - - async def __aexit__(self, *args): - if self._client: - await self._client.aclose() - self._client = None - - async def _login(self): - if not self.username or not self.password: - raise XuiApiError('Provide 3x-ui API token or username/password') - resp = await self._client.post('/login', json={ - 'username': self.username, - 'password': self.password, - }) - if resp.status_code != 200: - raise XuiApiError(f'3x-ui login failed: HTTP {resp.status_code}') - try: - body = resp.json() - except Exception: - body = {} - if body.get('success') is False: - raise XuiApiError(f"3x-ui login failed: {body.get('msg', 'unknown error')}") - - async def _request(self, method: str, path: str, **kwargs) -> Any: - assert self._client is not None - resp = await self._client.request(method, path, **kwargs) - try: - payload = resp.json() - except Exception: - raise XuiApiError(f'3x-ui {method} {path}: HTTP {resp.status_code} non-JSON') - if resp.status_code >= 400: - raise XuiApiError( - f"3x-ui {method} {path}: HTTP {resp.status_code} {payload.get('msg') or payload}" - ) - if isinstance(payload, dict) and payload.get('success') is False: - raise XuiApiError(payload.get('msg') or f'3x-ui {method} {path} failed') - return payload - - async def list_inbounds(self) -> list: - for path, method in ( - ('/panel/api/inbounds/list', 'GET'), - ('/panel/api/inbounds/list', 'POST'), - ('/panel/inbound/list', 'POST'), - ): - try: - payload = await self._request(method, path) - except XuiApiError: - continue - obj = payload.get('obj') if isinstance(payload, dict) else None - if isinstance(obj, list): - return obj - return [] - - async def list_vless_inbounds(self) -> list: - result = [] - for inbound in await self.list_inbounds(): - if not isinstance(inbound, dict): - continue - proto = (inbound.get('protocol') or '').lower() - if proto != 'vless': - continue - result.append({ - 'id': inbound.get('id'), - 'remark': inbound.get('remark') or f"VLESS:{inbound.get('port')}", - 'port': inbound.get('port'), - 'protocol': proto, - 'enable': bool(inbound.get('enable', True)), - }) - return result - - async def get_inbound(self, inbound_id: int) -> dict: - inbound_id = int(inbound_id) - for inbound in await self.list_inbounds(): - if isinstance(inbound, dict) and int(inbound.get('id') or 0) == inbound_id: - return inbound - raise XuiApiError(f'Inbound #{inbound_id} not found on 3x-ui') - - @staticmethod - def _parse_inbound_settings(inbound: dict) -> dict: - settings_raw = inbound.get('settings') or '{}' - if isinstance(settings_raw, str): - try: - settings_obj = json.loads(settings_raw) - except Exception: - settings_obj = {} - else: - settings_obj = settings_raw if isinstance(settings_raw, dict) else {} - return settings_obj if isinstance(settings_obj, dict) else {} - - def _client_template_from_inbound(self, inbound: dict) -> dict: - """Copy only fields 3x-ui already uses on this inbound — do not invent extras.""" - settings_obj = self._parse_inbound_settings(inbound) - clients = settings_obj.get('clients') or [] - template = next((c for c in clients if isinstance(c, dict)), None) or {} - flow = template.get('flow') - if not isinstance(flow, str): - flow = '' - # If no existing clients, detect Reality → vision (required for those inbounds) - if not flow: - stream_raw = inbound.get('streamSettings') or '{}' - if isinstance(stream_raw, str): - try: - stream = json.loads(stream_raw) - except Exception: - stream = {} - else: - stream = stream_raw if isinstance(stream_raw, dict) else {} - security = (stream.get('security') or '').lower() - network = (stream.get('network') or '').lower() - if security == 'reality' and network in ('tcp', 'raw', ''): - flow = 'xtls-rprx-vision' - return { - 'flow': flow, - 'limitIp': int(template.get('limitIp') or 0), - 'totalGB': 0, - 'tgId': '' if template.get('tgId') in (None, 0) else str(template.get('tgId') or ''), - } - - async def add_vless_client( - self, - *, - email: str, - inbound_id: int, - enable: bool = True, - expiry_time: int = 0, - ) -> dict: - """Add client via 3x-ui addClient only. Minimal payload; flow taken from inbound.""" - email = (email or '').strip() - if not email: - raise XuiApiError('Client email is required') - if not inbound_id: - raise XuiApiError('VLESS inbound id is required') - - inbound = await self.get_inbound(int(inbound_id)) - if (inbound.get('protocol') or '').lower() != 'vless': - raise XuiApiError(f'Inbound #{inbound_id} is not VLESS') - - tmpl = self._client_template_from_inbound(inbound) - client_uuid = str(uuid.uuid4()) - sub_id = _new_sub_id() - - # Minimal client object — nothing beyond what 3x-ui expects for this inbound - client = { - 'id': client_uuid, - 'email': email, - 'enable': bool(enable), - 'flow': tmpl['flow'], - 'limitIp': tmpl['limitIp'], - 'totalGB': 0, - 'expiryTime': int(expiry_time or 0), - 'tgId': tmpl['tgId'], - 'subId': sub_id, - } - - # Official path: addClient with settings as JSON string (object form breaks some builds) - await self._request( - 'POST', - '/panel/api/inbounds/addClient', - json={ - 'id': int(inbound_id), - 'settings': json.dumps({'clients': [client]}), - }, - ) - - # Share links ONLY from the panel — never build vless:// here - links = await self.get_client_links(email) - if not links: - links = await self.get_sub_links(sub_id) - - return { - 'client_id': email, - 'uuid': client_uuid, - 'sub_id': sub_id, - 'email': email, - 'config': '', - 'links': links, - 'expiry_time': int(expiry_time or 0), - 'inbound_id': int(inbound_id), - } - - async def get_client_links(self, email: str) -> list: - email = (email or '').strip() - if not email: - return [] - for path in ( - f'/panel/api/clients/links/{quote(email, safe="")}', - f'/panel/api/inbounds/clientLinks/0/{quote(email, safe="")}', - ): - try: - payload = await self._request('GET', path) - obj = payload.get('obj') if isinstance(payload, dict) else None - if isinstance(obj, list): - return [x for x in obj if isinstance(x, str) and x.strip()] - except XuiApiError as e: - logger.warning('get_client_links %s failed: %s', path, e) - return [] - - async def get_sub_links(self, sub_id: str) -> list: - """Ask 3x-ui for protocol URLs produced for this subscription id.""" - sub_id = (sub_id or '').strip() - if not sub_id: - return [] - path = f'/panel/api/inbounds/getSubLinks/{quote(sub_id, safe="")}' - try: - payload = await self._request('GET', path) - obj = payload.get('obj') if isinstance(payload, dict) else None - if isinstance(obj, list): - return [x for x in obj if isinstance(x, str) and x.strip()] - except XuiApiError as e: - logger.warning('getSubLinks failed: %s', e) - return [] - - async def get_panel_sub_base(self) -> str: - """Try to read public subscription base URL from 3x-ui panel settings.""" - for path, method in ( - ('/panel/api/setting', 'GET'), - ('/panel/setting/all', 'POST'), - ('/panel/api/settings', 'GET'), - ): - try: - payload = await self._request(method, path) - except XuiApiError: - continue - obj = payload.get('obj') if isinstance(payload, dict) else payload - if not isinstance(obj, dict): - continue - # Common field names across 3x-ui versions - sub_uri = (obj.get('subURI') or obj.get('subUri') or obj.get('sub_uri') or '').strip() - sub_path = (obj.get('subPath') or obj.get('sub_path') or '/sub').strip() or '/sub' - sub_port = obj.get('subPort') or obj.get('sub_port') - sub_listen = (obj.get('subListen') or '').strip() - if sub_uri: - return sub_uri.rstrip('/') - # Build from panel host + sub path if only path is configured - if sub_path and self.base_url: - from urllib.parse import urlparse, urlunparse - parsed = urlparse(self.base_url) - host = sub_listen or parsed.hostname or '' - if not host: - continue - scheme = 'https' if parsed.scheme == 'https' else 'http' - netloc = f'{host}:{sub_port}' if sub_port else host - path_part = sub_path if sub_path.startswith('/') else f'/{sub_path}' - return urlunparse((scheme, netloc, path_part.rstrip('/'), '', '', '')) - return '' - - async def resolve_share_link(self, *, email: str, sub_id: str = '', configured_sub_base: str = '') -> dict: - """Use only links returned by 3x-ui. Do not synthesize protocol configs.""" - email = (email or '').strip() - sub_id = (sub_id or '').strip() - - links: list = [] - if email: - links = await self.get_client_links(email) - if not links and sub_id: - links = await self.get_sub_links(sub_id) - - # Prefer protocol share from panel; then HTTP(S) subscription URL from panel - panel_proto = next( - (u for u in links if isinstance(u, str) and u.startswith(('vless://', 'vmess://', 'trojan://', 'ss://'))), - '', - ) - panel_http = next( - (u for u in links if isinstance(u, str) and u.startswith(('http://', 'https://'))), - '', - ) - - # Subscription URL: panel HTTP link first; optional configured base only as last resort - base = (configured_sub_base or '').strip().rstrip('/') - if not panel_http and not base: - try: - base = (await self.get_panel_sub_base() or '').rstrip('/') - except Exception as e: - logger.warning('get_panel_sub_base failed: %s', e) - base = '' - subscription_url = panel_http or (f'{base}/{sub_id}' if base and sub_id else '') - - # Prefer panel-generated protocol link (real config); else subscription URL - share = panel_proto or subscription_url or (links[0] if links else '') - return { - 'subscription_url': subscription_url, - 'links': links, - 'share': share, - 'sub_base': base, - } - - - async def delete_client(self, email: str) -> None: - email = (email or '').strip() - if not email: - return - path = f'/panel/api/clients/del/{quote(email, safe="")}' - try: - await self._request('POST', path) - return - except XuiApiError as e: - logger.warning('clients/del failed (%s), trying legacy', e) - # Legacy: need inbound id — try remove by scanning - for inbound in await self.list_inbounds(): - if not isinstance(inbound, dict): - continue - settings = inbound.get('settings') - if isinstance(settings, str): - try: - settings = json.loads(settings) - except Exception: - settings = {} - clients = (settings or {}).get('clients') if isinstance(settings, dict) else None - if not isinstance(clients, list): - continue - match = next((c for c in clients if isinstance(c, dict) and c.get('email') == email), None) - if not match: - continue - cid = match.get('id') or email - inbound_id = inbound.get('id') - for path in ( - f'/panel/api/inbounds/{inbound_id}/delClient/{quote(str(cid), safe="")}', - f'/panel/api/inbounds/{inbound_id}/delClientByEmail/{quote(email, safe="")}', - ): - try: - await self._request('POST', path) - return - except XuiApiError: - continue - raise XuiApiError(f'Failed to delete 3x-ui client {email}') - - async def set_client_enabled(self, email: str, enable: bool) -> None: - email = (email or '').strip() - if not email: - return - # Prefer full client get + update - try: - payload = await self._request('GET', f'/panel/api/clients/get/{quote(email, safe="")}') - obj = payload.get('obj') if isinstance(payload, dict) else None - client = None - if isinstance(obj, dict): - client = obj.get('client') if isinstance(obj.get('client'), dict) else obj - if isinstance(client, dict): - client = dict(client) - client['enable'] = bool(enable) - client['email'] = email - await self._request( - 'POST', - f'/panel/api/clients/update/{quote(email, safe="")}', - json=client, - ) - return - except XuiApiError as e: - logger.warning('toggle via clients/update failed: %s', e) - raise XuiApiError(f'Failed to toggle 3x-ui client {email}') - - -async def xui_create_vless_config( - settings: dict, - *, - name: str, - inbound_id: Optional[int] = None, - expiry_time: int = 0, - panel_id: Optional[str] = None, -) -> dict: - """Create client on selected 3x-ui inbound; return only links from the panel.""" - scoped = _resolve_settings(settings, panel_id) - creds = _settings_creds(scoped) - inbound = inbound_id if inbound_id not in (None, 0, '0') else creds.get('inbound_id') - try: - inbound = int(inbound or 0) - except (TypeError, ValueError): - inbound = 0 - if not inbound: - raise XuiApiError('Select a VLESS inbound from 3x-ui') - - async with XuiApi.from_panel_settings(scoped) as api: - base = ''.join(ch if ch.isalnum() or ch in '._-+@' else '_' for ch in (name or 'user').strip()) - base = base[:48] or f'user_{secrets.token_hex(4)}' - email = base - created = None - for _ in range(5): - try: - created = await api.add_vless_client( - email=email, - inbound_id=inbound, - expiry_time=int(expiry_time or 0), - ) - break - except XuiApiError as e: - if 'exist' in str(e).lower() or 'duplicate' in str(e).lower() or 'already' in str(e).lower(): - email = f'{base}_{secrets.token_hex(3)}' - continue - raise - if created is None: - created = await api.add_vless_client( - email=email, - inbound_id=inbound, - expiry_time=int(expiry_time or 0), - ) - - share = await api.resolve_share_link( - email=created.get('email') or email, - sub_id=created.get('sub_id') or '', - configured_sub_base=creds.get('sub_url') or '', - ) - panel_links = share.get('links') or created.get('links') or [] - created['links'] = panel_links - created['subscription_url'] = share.get('subscription_url') or '' - # Prefer real vless:// (etc.) from 3x-ui; subscription URL only as fallback - created['config'] = share.get('share') or '' - if not created['config'] and panel_links: - created['config'] = panel_links[0] - created['inbound_id'] = int(created.get('inbound_id') or inbound) - created['panel_id'] = panel_id or '' - return created - - -async def xui_get_config(settings: dict, email: str, panel_id: Optional[str] = None) -> str: - scoped = _resolve_settings(settings, panel_id) - creds = _settings_creds(scoped) - async with XuiApi.from_panel_settings(scoped) as api: - sub_id = '' - try: - for inbound in await api.list_inbounds(): - if not isinstance(inbound, dict): - continue - settings_raw = inbound.get('settings') or '{}' - if isinstance(settings_raw, str): - try: - settings_obj = json.loads(settings_raw) - except Exception: - settings_obj = {} - else: - settings_obj = settings_raw if isinstance(settings_raw, dict) else {} - for c in settings_obj.get('clients') or []: - if isinstance(c, dict) and (c.get('email') or '') == email: - sub_id = (c.get('subId') or c.get('sub_id') or '').strip() - break - if sub_id: - break - except Exception as e: - logger.warning('Could not resolve subId for %s: %s', email, e) - - share = await api.resolve_share_link( - email=email, - sub_id=sub_id, - configured_sub_base=creds.get('sub_url') or '', - ) - if share.get('share'): - return share['share'] - links = share.get('links') or await api.get_client_links(email) - vless = next((u for u in links if isinstance(u, str) and u.startswith('vless://')), None) - if vless: - return vless - if links: - return links[0] - raise XuiApiError(f'No share links for 3x-ui client {email}') - - -async def xui_delete_client(settings: dict, email: str, panel_id: Optional[str] = None) -> None: - scoped = _resolve_settings(settings, panel_id) - async with XuiApi.from_panel_settings(scoped) as api: - await api.delete_client(email) - - -async def xui_toggle_client(settings: dict, email: str, enable: bool, panel_id: Optional[str] = None) -> None: - scoped = _resolve_settings(settings, panel_id) - async with XuiApi.from_panel_settings(scoped) as api: - await api.set_client_enabled(email, enable) - - -async def xui_list_vless_inbounds(settings: dict, panel_id: Optional[str] = None) -> list: - scoped = _resolve_settings(settings, panel_id) - async with XuiApi.from_panel_settings(scoped) as api: - return await api.list_vless_inbounds() diff --git a/managers/xui_servers.py b/managers/xui_servers.py deleted file mode 100644 index 8683e0d..0000000 --- a/managers/xui_servers.py +++ /dev/null @@ -1,214 +0,0 @@ -"""Multi-panel 3x-ui server registry stored in settings.xui_servers.""" - -from __future__ import annotations - -import uuid -from typing import Any, Optional - - -def _new_id() -> str: - return str(uuid.uuid4()) - - -def normalize_server(raw: dict | None) -> dict: - raw = raw or {} - return { - 'id': str(raw.get('id') or _new_id()), - 'name': (raw.get('name') or '').strip() or '3x-ui', - 'url': (raw.get('url') or '').strip().rstrip('/'), - 'sub_url': (raw.get('sub_url') or '').strip().rstrip('/'), - 'api_token': (raw.get('api_token') or '').strip(), - 'username': (raw.get('username') or '').strip(), - 'password': raw.get('password') or '', - 'default_inbound_id': int(raw.get('default_inbound_id') or 0), - 'enabled': bool(raw.get('enabled', True)), - 'sync_users': bool(raw.get('sync_users', False)), - } - - -def legacy_sync_to_server(sync: dict) -> Optional[dict]: - """Build one xui_servers entry from old settings.sync.xui_* fields.""" - sync = sync or {} - url = (sync.get('xui_url') or '').strip() - if not url: - return None - return normalize_server({ - 'id': 'legacy-default', - 'name': '3x-ui (default)', - 'url': url, - 'sub_url': sync.get('xui_sub_url') or '', - 'api_token': sync.get('xui_api_token') or '', - 'username': sync.get('xui_username') or '', - 'password': sync.get('xui_password') or '', - 'default_inbound_id': int(sync.get('xui_inbound_id') or 0), - 'enabled': True, - 'sync_users': bool(sync.get('xui_sync_users')), - }) - - -def ensure_xui_servers(settings: dict) -> list: - """Ensure settings['xui_servers'] exists; migrate legacy sync fields once.""" - settings = settings if isinstance(settings, dict) else {} - servers = settings.get('xui_servers') - if not isinstance(servers, list): - servers = [] - servers = [normalize_server(s) for s in servers if isinstance(s, dict)] - - if not servers: - migrated = legacy_sync_to_server(settings.get('sync') or {}) - if migrated: - servers = [migrated] - settings['xui_servers'] = servers - - # 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: - if s.get('enabled'): - primary = s - break - if primary is None and servers: - primary = servers[0] - if primary and primary.get('url'): - sync.setdefault('xui_url', primary.get('url') or '') - sync.setdefault('xui_sub_url', primary.get('sub_url') or '') - sync.setdefault('xui_api_token', primary.get('api_token') or '') - sync.setdefault('xui_username', primary.get('username') or '') - sync.setdefault('xui_password', primary.get('password') or '') - sync.setdefault('xui_inbound_id', int(primary.get('default_inbound_id') or 0)) - return servers - - -def list_xui_servers(settings: dict, *, enabled_only: bool = False) -> list: - servers = ensure_xui_servers(settings) - if enabled_only: - return [s for s in servers if s.get('enabled')] - return servers - - -def get_xui_server(settings: dict, panel_id: Optional[str] = None) -> Optional[dict]: - servers = ensure_xui_servers(settings) - if not servers: - return None - if panel_id: - for s in servers: - if str(s.get('id')) == str(panel_id): - return s - return None - for s in servers: - if s.get('enabled'): - return s - return servers[0] - - -def public_server_view(server: dict) -> dict: - """Safe view for UI (no password/token secrets in list if desired — we keep them for admin edit).""" - return { - 'id': server.get('id'), - 'name': server.get('name') or '3x-ui', - 'url': server.get('url') or '', - 'sub_url': server.get('sub_url') or '', - 'api_token': server.get('api_token') or '', - 'username': server.get('username') or '', - 'password': server.get('password') or '', - 'default_inbound_id': int(server.get('default_inbound_id') or 0), - 'enabled': bool(server.get('enabled', True)), - 'sync_users': bool(server.get('sync_users', False)), - 'has_token': bool(server.get('api_token')), - 'has_password': bool(server.get('password')), - } - - -def upsert_xui_server(settings: dict, payload: dict, *, panel_id: Optional[str] = None) -> dict: - servers = ensure_xui_servers(settings) - panel_id = panel_id or payload.get('id') - existing = None - if panel_id: - for s in servers: - if str(s.get('id')) == str(panel_id): - existing = s - break - - merged = dict(existing or {}) - for key in ('name', 'url', 'sub_url', 'api_token', 'username', 'password'): - if key in payload and payload[key] is not None: - # Empty password/token on edit means "keep previous" when editing - if key in ('password', 'api_token') and existing and payload[key] == '': - continue - merged[key] = payload[key] - if 'default_inbound_id' in payload and payload['default_inbound_id'] is not None: - merged['default_inbound_id'] = int(payload['default_inbound_id'] or 0) - if 'enabled' in payload and payload['enabled'] is not None: - merged['enabled'] = bool(payload['enabled']) - if 'sync_users' in payload and payload['sync_users'] is not None: - merged['sync_users'] = bool(payload['sync_users']) - if not merged.get('id'): - merged['id'] = _new_id() - - server = normalize_server(merged) - if not server['url']: - raise ValueError('3x-ui URL is required') - if not server['name']: - server['name'] = server['url'] - - if existing: - for i, s in enumerate(servers): - if str(s.get('id')) == str(existing.get('id')): - servers[i] = server - break - else: - servers.append(server) - - settings['xui_servers'] = servers - _mirror_primary_to_sync(settings) - return server - - -def delete_xui_server(settings: dict, panel_id: str) -> bool: - servers = ensure_xui_servers(settings) - new_list = [s for s in servers if str(s.get('id')) != str(panel_id)] - if len(new_list) == len(servers): - return False - settings['xui_servers'] = new_list - _mirror_primary_to_sync(settings) - return True - - -def _mirror_primary_to_sync(settings: dict) -> None: - sync = settings.setdefault('sync', {}) - primary = get_xui_server(settings, None) - if not primary: - sync['xui_url'] = '' - sync['xui_sub_url'] = '' - return - sync['xui_url'] = primary.get('url') or '' - sync['xui_sub_url'] = primary.get('sub_url') or '' - sync['xui_api_token'] = primary.get('api_token') or '' - sync['xui_username'] = primary.get('username') or '' - sync['xui_password'] = primary.get('password') or '' - sync['xui_inbound_id'] = int(primary.get('default_inbound_id') or 0) - if any(s.get('sync_users') for s in settings.get('xui_servers') or []): - sync['xui_sync_users'] = True - - -def server_to_settings_slice(server: dict) -> dict: - """Shape expected by xui_api._settings_creds / from_panel_settings.""" - return { - 'sync': { - 'xui_url': server.get('url') or '', - 'xui_sub_url': server.get('sub_url') or '', - 'xui_api_token': server.get('api_token') or '', - 'xui_username': server.get('username') or '', - 'xui_password': server.get('password') or '', - 'xui_inbound_id': int(server.get('default_inbound_id') or 0), - } - } - - -def resolve_panel_settings(full_settings: dict, panel_id: Optional[str] = None) -> dict: - """Return a settings-like dict scoped to one 3x-ui panel (fallback: primary / legacy).""" - server = get_xui_server(full_settings, panel_id) - if server: - return server_to_settings_slice(server) - # Fallback to legacy global sync - return {'sync': (full_settings or {}).get('sync') or {}} diff --git a/templates/guest.html b/templates/guest.html index 542152c..2c7594d 100644 --- a/templates/guest.html +++ b/templates/guest.html @@ -139,7 +139,6 @@ 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 a1bfa2c..221b222 100644 --- a/templates/invites.html +++ b/templates/invites.html @@ -17,18 +17,6 @@ - {% if not xui_has_sub_url and xui_configured %} -
-
-
⚠️
-
-
{{ _('invite_sub_url_missing_title') }}
-
{{ _('invite_sub_url_missing_hint') }}
-
-
-
- {% endif %} -
🔗
{{ _('invites_empty') }}
@@ -42,12 +30,7 @@
{{ inv.name }}
- {% 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 %} + {{ inv.protocol }}
{% if inv.available %} @@ -127,8 +110,7 @@
- {% for s in servers %} {% set server_idx = loop.index0 %} {% for key, info in (s.protocols or {}).items() %} @@ -140,21 +122,6 @@
-
- - -
{{ _('xui_server_select_hint') }}
- - -
{{ _('invite_inbound_hint') }}
-
-
@@ -186,9 +153,6 @@