diff --git a/app.py b/app.py index cb5789c..4c241bb 100644 --- a/app.py +++ b/app.py @@ -3905,12 +3905,14 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo 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', - # Optional hint only — panel assigns inbound + share link - inbound_id=req.xui_inbound_id or panel.get('default_inbound_id') or None, + inbound_id=inbound, panel_id=panel_id, ) result = { @@ -4498,8 +4500,9 @@ async def _create_config_for_protocol( if not panel: raise RuntimeError('Add a 3x-ui server in Settings first') panel_id = panel.get('id') or '' - # Prefer invite/default inbound; otherwise 3x-ui picks first VLESS on that panel - inbound = xui_inbound_id or panel.get('default_inbound_id') or None + 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', {}), @@ -4611,9 +4614,10 @@ async def api_create_invite(request: Request, req: InviteCreateRequest): if not panel: return JSONResponse({'error': 'Add a 3x-ui server in Settings first'}, status_code=400) panel_id = panel.get('id') or '' - # Inbound is optional — 3x-ui panel assigns it (or uses server default) on redeem 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', @@ -4686,6 +4690,10 @@ async def api_update_invite(request: Request, invite_id: str, req: InviteUpdateR 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)} diff --git a/managers/xui_api.py b/managers/xui_api.py index 9b4a96a..76036f2 100644 --- a/managers/xui_api.py +++ b/managers/xui_api.py @@ -158,79 +158,113 @@ class XuiApi: }) 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, - comment: str = '', enable: bool = True, - total_gb: int = 0, expiry_time: int = 0, - limit_ip: int = 0, - flow: str = '', ) -> 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': enable, - 'flow': flow or '', - 'limitIp': limit_ip, - 'totalGB': total_gb, - 'expiryTime': expiry_time, - 'tgId': 0, + 'enable': bool(enable), + 'flow': tmpl['flow'], + 'limitIp': tmpl['limitIp'], + 'totalGB': 0, + 'expiryTime': int(expiry_time or 0), + 'tgId': tmpl['tgId'], 'subId': sub_id, - 'comment': comment or '', } - # Modern API - try: - await self._request( - 'POST', - '/panel/api/clients/add', - json={'client': client, 'inboundIds': [int(inbound_id)]}, - ) - except XuiApiError as modern_err: - logger.info('Modern clients/add failed (%s), trying legacy addClient', modern_err) - # Legacy: settings must be a JSON-encoded string on many builds - settings_obj = {'clients': [client]} - try: - await self._request( - 'POST', - '/panel/api/inbounds/addClient', - json={ - 'id': int(inbound_id), - 'settings': json.dumps(settings_obj), - }, - ) - except XuiApiError: - # Some builds accept nested object - await self._request( - 'POST', - '/panel/api/inbounds/addClient', - json={ - 'id': int(inbound_id), - 'settings': settings_obj, - }, - ) + # 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) - # Share URL is resolved by the caller via resolve_share_link (panel assigns link) return { 'client_id': email, 'uuid': client_uuid, 'sub_id': sub_id, 'email': email, 'config': '', - 'links': [], - 'expiry_time': expiry_time, + 'links': links, + 'expiry_time': int(expiry_time or 0), 'inbound_id': int(inbound_id), } @@ -301,33 +335,38 @@ class XuiApi: return '' async def resolve_share_link(self, *, email: str, sub_id: str = '', configured_sub_base: str = '') -> dict: - """Ask the panel for the shareable subscription / protocol link (do not invent config).""" + """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 base: + 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 = '' - - links = [] - if sub_id: - links = await self.get_sub_links(sub_id) - if not links and email: - links = await self.get_client_links(email) - - # Prefer HTTP(S) link returned by 3x-ui; else build from panel/settings sub base + subId - panel_http = next( - (u for u in links if isinstance(u, str) and u.startswith(('http://', 'https://'))), - '', - ) subscription_url = panel_http or (f'{base}/{sub_id}' if base and sub_id else '') - share = subscription_url or next( - (u for u in links if isinstance(u, str) and u.startswith(('vless://', 'vmess://', 'trojan://', 'ss://'))), - '', - ) + + # 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, @@ -335,6 +374,7 @@ class XuiApi: 'sub_base': base, } + async def delete_client(self, email: str) -> None: email = (email or '').strip() if not email: @@ -408,7 +448,7 @@ async def xui_create_vless_config( expiry_time: int = 0, panel_id: Optional[str] = None, ) -> dict: - """Create a client via 3x-ui API; inbound + share link come from the panel response.""" + """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') @@ -416,15 +456,10 @@ async def xui_create_vless_config( 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: - # Panel assigns inbound: use configured default, else first VLESS on that panel - if not inbound: - vless_inbounds = await api.list_vless_inbounds() - if not vless_inbounds: - raise XuiApiError('No VLESS inbound found on 3x-ui — create one in the panel first') - inbound = int(vless_inbounds[0]['id']) - 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 @@ -434,7 +469,6 @@ async def xui_create_vless_config( created = await api.add_vless_client( email=email, inbound_id=inbound, - comment=name or email, expiry_time=int(expiry_time or 0), ) break @@ -447,24 +481,19 @@ async def xui_create_vless_config( created = await api.add_vless_client( email=email, inbound_id=inbound, - comment=name or email, expiry_time=int(expiry_time or 0), ) - # Ask the panel for the subscription / protocol link (do not invent config here) 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 '', ) - subscription_url = share.get('subscription_url') or '' - panel_share = share.get('share') or '' panel_links = share.get('links') or created.get('links') or [] - - created['subscription_url'] = subscription_url created['links'] = panel_links - # What we show the user: panel subscription URL, else panel protocol link - created['config'] = subscription_url or panel_share or created.get('config') or '' + 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) diff --git a/templates/invites.html b/templates/invites.html index b6a8623..a1bfa2c 100644 --- a/templates/invites.html +++ b/templates/invites.html @@ -44,6 +44,7 @@
{% 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 %} @@ -141,12 +142,17 @@
- {% for s in xui_servers %} {% endfor %}
{{ _('xui_server_select_hint') }}
+ + +
{{ _('invite_inbound_hint') }}
@@ -181,6 +187,7 @@