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:
orohi
2026-07-26 03:19:09 +03:00
co-authored by Cursor
parent 83bb73179b
commit 8abe692624
14 changed files with 767 additions and 104 deletions
+50 -11
View File
@@ -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()
+211
View File
@@ -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 {}}