Template
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 <cursoragent@cursor.com>
This commit is contained in:
+129
-28
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user