From 8dc090bd672a3a8691f1b784d1806e1120ce29a6 Mon Sep 17 00:00:00 2001 From: orohi Date: Sun, 26 Jul 2026 03:40:24 +0300 Subject: [PATCH] Let 3x-ui assign inbound and share link on create. Stop requiring inbound selection in the site UI; send the create request to the chosen panel and use the link it returns. Co-authored-by: Cursor --- app.py | 22 ++++-- managers/xui_api.py | 157 +++++++++++++++++++++++++++++++++------- templates/invites.html | 56 +++----------- templates/settings.html | 61 ++-------------- templates/users.html | 35 +-------- translations/en.json | 6 +- translations/ru.json | 6 +- 7 files changed, 169 insertions(+), 174 deletions(-) diff --git a/app.py b/app.py index 713bd4e..cb5789c 100644 --- a/app.py +++ b/app.py @@ -3909,6 +3909,7 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo 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, panel_id=panel_id, ) @@ -4490,8 +4491,6 @@ async def _create_config_for_protocol( """Create VPN client; returns {client_id, config, subscription_url, protocol, server_id}.""" protocol = protocol or 'xui' if protocol_base(protocol) == 'xui': - if not xui_inbound_id: - raise RuntimeError('Select a VLESS inbound for this invite link') 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 {}) @@ -4499,11 +4498,13 @@ 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 expiry_ms = _expiry_ms_from_duration_days(duration_days) created = await xui_create_vless_config( data.get('settings', {}), name=name, - inbound_id=xui_inbound_id, + inbound_id=inbound, expiry_time=expiry_ms, panel_id=panel_id, ) @@ -4515,6 +4516,7 @@ async def _create_config_for_protocol( '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 @@ -4609,10 +4611,9 @@ 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'}, status_code=400) link = { 'id': str(uuid.uuid4()), 'name': (req.name or 'Invite').strip() or 'Invite', @@ -4677,9 +4678,14 @@ 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 - # Validate inbound for xui - if protocol_base(link.get('protocol') or 'xui') == 'xui' and not int(link.get('xui_inbound_id') or 0): - return JSONResponse({'error': 'Select a VLESS inbound'}, status_code=400) + 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 '' save_data(data) return {'status': 'success', 'invite': _invite_public_view(link)} diff --git a/managers/xui_api.py b/managers/xui_api.py index fdf2a7d..9b4a96a 100644 --- a/managers/xui_api.py +++ b/managers/xui_api.py @@ -222,34 +222,119 @@ class XuiApi: }, ) - links = await self.get_client_links(email) - vless = next((u for u in links if isinstance(u, str) and u.startswith('vless://')), None) - if not vless and links: - vless = links[0] + # 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': vless or '', - 'links': links, + 'config': '', + 'links': [], 'expiry_time': expiry_time, + 'inbound_id': int(inbound_id), } async def get_client_links(self, email: str) -> list: email = (email or '').strip() if not email: return [] - path = f'/panel/api/clients/links/{quote(email, safe="")}' + 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('get_client_links failed: %s', 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: + """Ask the panel for the shareable subscription / protocol link (do not invent config).""" + email = (email or '').strip() + sub_id = (sub_id or '').strip() + base = (configured_sub_base or '').strip().rstrip('/') + if 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://'))), + '', + ) + 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: @@ -323,28 +408,27 @@ async def xui_create_vless_config( expiry_time: int = 0, panel_id: Optional[str] = None, ) -> dict: - """Create a VLESS client on 3x-ui and return {client_id, config, subscription_url, links}.""" + """Create a client via 3x-ui API; inbound + share link come from the panel response.""" scoped = _resolve_settings(settings, panel_id) creds = _settings_creds(scoped) - inbound = inbound_id if inbound_id is not None else creds.get('inbound_id') + inbound = inbound_id if inbound_id not in (None, 0, '0') else creds.get('inbound_id') try: - inbound = int(inbound) + inbound = int(inbound or 0) except (TypeError, ValueError): inbound = 0 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']) - # Sanitize email: 3x-ui emails are unique free-form ids 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 - # Avoid collisions for _ in range(5): try: created = await api.add_vless_client( @@ -367,22 +451,32 @@ async def xui_create_vless_config( expiry_time=int(expiry_time or 0), ) - sub_url = build_subscription_url(scoped, created.get('sub_id') or '') - # Prefer subscription URL as the main share string when configured - if sub_url: - created['subscription_url'] = sub_url - created['config'] = sub_url - else: - created['subscription_url'] = '' - created['inbound_id'] = inbound + # 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 '' + 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: - # Prefer subscription URL when subId is available on the client + sub_id = '' try: for inbound in await api.list_inbounds(): if not isinstance(inbound, dict): @@ -398,14 +492,21 @@ async def xui_get_config(settings: dict, email: str, panel_id: Optional[str] = N 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() - sub_url = build_subscription_url(scoped, sub_id) - if sub_url: - return sub_url + break + if sub_id: + break except Exception as e: - logger.warning('Could not resolve subscription URL for %s: %s', email, e) + logger.warning('Could not resolve subId for %s: %s', email, e) - links = await api.get_client_links(email) - vless = next((u for u in links if u.startswith('vless://')), None) + 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: diff --git a/templates/invites.html b/templates/invites.html index d955e3a..b6a8623 100644 --- a/templates/invites.html +++ b/templates/invites.html @@ -42,8 +42,11 @@
{{ inv.name }}
- {% if inv.protocol == 'xui' %}3x-ui VLESS{% else %}{{ inv.protocol }}{% endif %} - · inbound #{{ inv.xui_inbound_id or '—' }} + {% 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 %} + {% else %} + {{ inv.protocol }} + {% endif %}
{% if inv.available %} @@ -138,17 +141,12 @@
- {% for s in xui_servers %} {% endfor %}
{{ _('xui_server_select_hint') }}
- - -
{{ _('invite_inbound_hint') }}
@@ -183,7 +181,6 @@