Template
Add multi-server 3x-ui registry with per-panel subscription URLs.
Admins can manage several 3x-ui panels by name and select which one issues invite/user configs via that server's /sub base URL. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+50
-11
@@ -47,6 +47,16 @@ def build_subscription_url(settings: dict, sub_id: str) -> str:
|
||||
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
|
||||
|
||||
@@ -311,16 +321,18 @@ async def xui_create_vless_config(
|
||||
name: str,
|
||||
inbound_id: Optional[int] = None,
|
||||
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}."""
|
||||
creds = _settings_creds(settings)
|
||||
scoped = _resolve_settings(settings, panel_id)
|
||||
creds = _settings_creds(scoped)
|
||||
inbound = inbound_id if inbound_id is not None else creds.get('inbound_id')
|
||||
try:
|
||||
inbound = int(inbound)
|
||||
except (TypeError, ValueError):
|
||||
inbound = 0
|
||||
|
||||
async with XuiApi.from_panel_settings(settings) as api:
|
||||
async with XuiApi.from_panel_settings(scoped) as api:
|
||||
if not inbound:
|
||||
vless_inbounds = await api.list_vless_inbounds()
|
||||
if not vless_inbounds:
|
||||
@@ -355,7 +367,7 @@ async def xui_create_vless_config(
|
||||
expiry_time=int(expiry_time or 0),
|
||||
)
|
||||
|
||||
sub_url = build_subscription_url(settings, created.get('sub_id') or '')
|
||||
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
|
||||
@@ -363,11 +375,35 @@ async def xui_create_vless_config(
|
||||
else:
|
||||
created['subscription_url'] = ''
|
||||
created['inbound_id'] = inbound
|
||||
created['panel_id'] = panel_id or ''
|
||||
return created
|
||||
|
||||
|
||||
async def xui_get_config(settings: dict, email: str) -> str:
|
||||
async with XuiApi.from_panel_settings(settings) as api:
|
||||
async def xui_get_config(settings: dict, email: str, panel_id: Optional[str] = None) -> str:
|
||||
scoped = _resolve_settings(settings, panel_id)
|
||||
async with XuiApi.from_panel_settings(scoped) as api:
|
||||
# Prefer subscription URL when subId is available on the client
|
||||
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()
|
||||
sub_url = build_subscription_url(scoped, sub_id)
|
||||
if sub_url:
|
||||
return sub_url
|
||||
except Exception as e:
|
||||
logger.warning('Could not resolve subscription URL for %s: %s', email, e)
|
||||
|
||||
links = await api.get_client_links(email)
|
||||
vless = next((u for u in links if u.startswith('vless://')), None)
|
||||
if vless:
|
||||
@@ -377,16 +413,19 @@ async def xui_get_config(settings: dict, email: str) -> str:
|
||||
raise XuiApiError(f'No share links for 3x-ui client {email}')
|
||||
|
||||
|
||||
async def xui_delete_client(settings: dict, email: str) -> None:
|
||||
async with XuiApi.from_panel_settings(settings) as api:
|
||||
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) -> None:
|
||||
async with XuiApi.from_panel_settings(settings) as api:
|
||||
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) -> list:
|
||||
async with XuiApi.from_panel_settings(settings) as api:
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Multi-panel 3x-ui server registry stored in settings.xui_servers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
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
|
||||
else:
|
||||
settings['xui_servers'] = servers
|
||||
|
||||
# Keep legacy sync.* mirrored from first/default for older code paths
|
||||
sync = settings.setdefault('sync', {})
|
||||
primary = get_xui_server(settings, None) or {}
|
||||
if 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
|
||||
# Prefer enabled
|
||||
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 {}}
|
||||
Reference in New Issue
Block a user