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
+220 -30
View File
@@ -1713,6 +1713,7 @@ class AddConnectionRequest(BaseModel):
telemt_ad_tag: Optional[str] = None
telemt_max_conns: Optional[int] = None
xui_inbound_id: Optional[int] = None
xui_panel_id: Optional[str] = None
class EditConnectionRequest(BaseModel):
@@ -1800,6 +1801,19 @@ class SyncSettings(BaseModel):
xui_inbound_id: int = 0
xui_sub_url: str = ''
class XuiServerRequest(BaseModel):
name: str = ''
url: str = ''
sub_url: str = ''
api_token: str = ''
username: str = ''
password: str = ''
default_inbound_id: int = 0
enabled: bool = True
sync_users: bool = False
class CaptchaSettings(BaseModel):
enabled: bool = False
@@ -1828,6 +1842,7 @@ class GuestSettings(BaseModel):
create_protocol: str = 'xui'
create_server_id: int = 0
create_inbound_id: int = 0
create_xui_panel_id: str = ''
class UpdateUserRequest(BaseModel):
@@ -1866,6 +1881,7 @@ class AddUserConnectionRequest(BaseModel):
telemt_ad_tag: Optional[str] = None
telemt_max_conns: Optional[int] = None
xui_inbound_id: Optional[int] = None
xui_panel_id: Optional[str] = None
class CreateApiTokenRequest(BaseModel):
@@ -1892,6 +1908,7 @@ class InviteCreateRequest(BaseModel):
protocol: str = 'xui'
server_id: int = 0
xui_inbound_id: int = 0
xui_panel_id: str = ''
password: Optional[str] = None
duration_days: int = 0 # client lifetime after redeem; 0 = no expiry
note: str = ''
@@ -1905,6 +1922,7 @@ class InviteUpdateRequest(BaseModel):
protocol: Optional[str] = None
server_id: Optional[int] = None
xui_inbound_id: Optional[int] = None
xui_panel_id: Optional[str] = None
password: Optional[str] = None
clear_password: bool = False
duration_days: Optional[int] = None
@@ -2233,14 +2251,19 @@ async def users_page(request: Request):
for u in users_list:
u['connections_count'] = sum(1 for c in conns if c['user_id'] == u['id'])
servers = data['servers']
sync = data.get('settings', {}).get('sync', {})
xui_configured = bool((sync.get('xui_url') or '').strip())
from managers.xui_servers import list_xui_servers, ensure_xui_servers, get_xui_server
ensure_xui_servers(data.setdefault('settings', {}))
xui_servers = list_xui_servers(data.get('settings') or {})
primary = get_xui_server(data.get('settings') or {}, None)
xui_configured = bool(xui_servers)
return tpl(
request, 'users.html',
users=users_list,
servers=servers,
xui_configured=xui_configured,
xui_inbound_id=sync.get('xui_inbound_id') or 0,
xui_servers=xui_servers,
xui_inbound_id=int((primary or {}).get('default_inbound_id') or 0),
xui_default_panel_id=(primary or {}).get('id') or '',
)
@@ -3869,20 +3892,31 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo
status_code=400,
)
# 3x-ui VLESS — no SSH; uses panel settings credentials
# 3x-ui VLESS — no SSH; uses selected 3x-ui panel credentials
if protocol_base(req.protocol) == '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 {}, req.xui_panel_id)
if not panel:
return JSONResponse({'error': 'Add a 3x-ui server in Settings first'}, status_code=400)
panel_id = panel.get('id') or ''
if req.client_id:
from managers.xui_api import xui_get_config
config = await xui_get_config(data.get('settings', {}), req.client_id)
result = {'client_id': req.client_id, 'config': config}
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:
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',
inbound_id=req.xui_inbound_id,
inbound_id=req.xui_inbound_id or panel.get('default_inbound_id') or None,
panel_id=panel_id,
)
result = {'client_id': created['client_id'], 'config': created.get('config') or ''}
result = {
'client_id': created['client_id'],
'config': created.get('config') or '',
'subscription_url': created.get('subscription_url') or '',
}
sid = req.server_id if data['servers'] and req.server_id < len(data['servers']) else 0
if result.get('client_id'):
conn = {
@@ -3892,6 +3926,7 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo
'protocol': 'xui',
'client_id': result['client_id'],
'name': req.name,
'xui_panel_id': panel_id,
'created_at': datetime.now().isoformat(),
}
async with DATA_LOCK:
@@ -3901,7 +3936,12 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo
resp = {'status': 'success'}
if result.get('config'):
resp['config'] = result['config']
resp['vpn_link'] = generate_vpn_link(result['config'])
sub = result.get('subscription_url') or ''
resp['subscription_url'] = sub
# Don't wrap subscription HTTPS URLs into vpn://
resp['vpn_link'] = sub if sub else (
result['config'] if str(result['config']).startswith('http') else generate_vpn_link(result['config'])
)
return resp
if req.server_id >= len(data['servers']):
@@ -4100,9 +4140,17 @@ async def api_share_config(token: str, connection_id: str, request: Request):
try:
if protocol_base(conn.get('protocol', '')) == 'xui':
from managers.xui_api import xui_get_config
config = await xui_get_config(data.get('settings', {}), conn['client_id'])
vpn_link = generate_vpn_link(config) if config else ''
return {'config': config, 'vpn_link': vpn_link}
config = await xui_get_config(
data.get('settings', {}),
conn['client_id'],
panel_id=conn.get('xui_panel_id') or None,
)
vpn_link = config if str(config).startswith('http') else (generate_vpn_link(config) if config else '')
return {
'config': config,
'vpn_link': vpn_link,
'subscription_url': config if str(config).startswith('http') else '',
}
sid = conn['server_id']
server = data['servers'][sid]
@@ -4136,6 +4184,7 @@ def _guest_settings(data: Optional[dict] = None) -> dict:
'create_protocol': 'xui',
'create_server_id': 0,
'create_inbound_id': 0,
'create_xui_panel_id': '',
}
defaults.update(guest)
return defaults
@@ -4245,9 +4294,17 @@ async def api_guest_config(token: str, connection_id: str, request: Request):
try:
if protocol_base(conn.get('protocol', '')) == 'xui':
from managers.xui_api import xui_get_config
config = await xui_get_config(data.get('settings', {}), conn['client_id'])
vpn_link = generate_vpn_link(config) if config else ''
return {'config': config, 'vpn_link': vpn_link}
config = await xui_get_config(
data.get('settings', {}),
conn['client_id'],
panel_id=conn.get('xui_panel_id') or None,
)
vpn_link = config if str(config).startswith('http') else (generate_vpn_link(config) if config else '')
return {
'config': config,
'vpn_link': vpn_link,
'subscription_url': config if str(config).startswith('http') else '',
}
sid = conn['server_id']
server = data['servers'][sid]
@@ -4284,12 +4341,24 @@ async def api_guest_create(token: str, req: GuestCreateRequest, request: Request
try:
if protocol_base(protocol) == 'xui':
from managers.xui_api import xui_create_vless_config
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 {}, guest.get('create_xui_panel_id') or None)
if not panel:
return JSONResponse({'error': 'Add a 3x-ui server in Settings first'}, status_code=400)
panel_id = panel.get('id') or ''
created = await xui_create_vless_config(
data.get('settings', {}),
name=name,
inbound_id=guest.get('create_inbound_id') or None,
inbound_id=guest.get('create_inbound_id') or panel.get('default_inbound_id') or None,
panel_id=panel_id,
)
result = {'client_id': created['client_id'], 'config': created.get('config') or ''}
result = {
'client_id': created['client_id'],
'config': created.get('config') or '',
'subscription_url': created.get('subscription_url') or '',
'xui_panel_id': panel_id,
}
sid = 0
protocol = 'xui'
else:
@@ -4323,6 +4392,7 @@ async def api_guest_create(token: str, req: GuestCreateRequest, request: Request
'protocol': protocol,
'client_id': result['client_id'],
'name': name,
'xui_panel_id': result.get('xui_panel_id') or '',
'created_at': datetime.now().isoformat(),
}
async with DATA_LOCK:
@@ -4331,11 +4401,13 @@ async def api_guest_create(token: str, req: GuestCreateRequest, request: Request
save_data(data)
config = result.get('config') or ''
vpn_link = generate_vpn_link(config) if config else ''
subscription_url = result.get('subscription_url') or ''
vpn_link = subscription_url or (config if str(config).startswith('http') else (generate_vpn_link(config) if config else ''))
return {
'status': 'success',
'connection': _enrich_guest_conn(conn, data),
'config': config,
'subscription_url': subscription_url,
'vpn_link': vpn_link,
}
except Exception as e:
@@ -4378,6 +4450,7 @@ def _invite_public_view(link: dict) -> dict:
'protocol': link.get('protocol') or 'xui',
'server_id': int(link.get('server_id') or 0),
'xui_inbound_id': int(link.get('xui_inbound_id') or 0),
'xui_panel_id': link.get('xui_panel_id') or '',
'duration_days': duration_days,
'user_id': link.get('user_id') or '',
'note': link.get('note') or '',
@@ -4411,6 +4484,7 @@ async def _create_config_for_protocol(
name: str,
server_id: int = 0,
xui_inbound_id: Optional[int] = None,
xui_panel_id: Optional[str] = None,
duration_days: int = 0,
) -> dict:
"""Create VPN client; returns {client_id, config, subscription_url, protocol, server_id}."""
@@ -4419,12 +4493,19 @@ async def _create_config_for_protocol(
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 {})
panel = get_xui_server(data.get('settings') or {}, xui_panel_id)
if not panel:
raise RuntimeError('Add a 3x-ui server in Settings first')
panel_id = panel.get('id') or ''
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,
expiry_time=expiry_ms,
panel_id=panel_id,
)
return {
'client_id': created['client_id'],
@@ -4433,6 +4514,7 @@ async def _create_config_for_protocol(
'sub_id': created.get('sub_id') or '',
'protocol': 'xui',
'server_id': 0,
'xui_panel_id': panel_id,
'expires_at': (
datetime.fromtimestamp(expiry_ms / 1000).isoformat()
if expiry_ms > 0 else None
@@ -4478,15 +4560,23 @@ async def invites_page(request: Request):
if user['role'] not in ('admin', 'support'):
return RedirectResponse(url='/my', status_code=302)
data = load_data()
from managers.xui_servers import list_xui_servers, get_xui_server, ensure_xui_servers
ensure_xui_servers(data.setdefault('settings', {}))
xui_servers = list_xui_servers(data.get('settings') or {})
primary = get_xui_server(data.get('settings') or {}, None)
any_sub = any((s.get('sub_url') or '').strip() for s in xui_servers)
return tpl(
request,
'invites.html',
invites=[_invite_public_view(x) for x in data.get('invite_links', [])],
users=data.get('users', []),
servers=data.get('servers', []),
xui_configured=bool(((data.get('settings') or {}).get('sync') or {}).get('xui_url')),
xui_sub_url=(((data.get('settings') or {}).get('sync') or {}).get('xui_sub_url') or ''),
xui_default_inbound=int((((data.get('settings') or {}).get('sync') or {}).get('xui_inbound_id') or 0)),
xui_servers=xui_servers,
xui_configured=bool(xui_servers),
xui_sub_url=(primary or {}).get('sub_url') or '',
xui_has_sub_url=any_sub,
xui_default_inbound=int((primary or {}).get('default_inbound_id') or 0),
xui_default_panel_id=(primary or {}).get('id') or '',
)
@@ -4511,8 +4601,18 @@ async def api_create_invite(request: Request, req: InviteCreateRequest):
return JSONResponse({'error': 'User not found'}, status_code=400)
protocol = req.protocol or 'xui'
inbound_id = int(req.xui_inbound_id or 0)
if protocol_base(protocol) == 'xui' and not inbound_id:
return JSONResponse({'error': 'Select a VLESS inbound'}, status_code=400)
panel_id = (req.xui_panel_id or '').strip()
if protocol_base(protocol) == '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 {}, panel_id or None)
if not panel:
return JSONResponse({'error': 'Add a 3x-ui server in Settings first'}, status_code=400)
panel_id = panel.get('id') or ''
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',
@@ -4524,6 +4624,7 @@ async def api_create_invite(request: Request, req: InviteCreateRequest):
'protocol': protocol,
'server_id': int(req.server_id or 0),
'xui_inbound_id': inbound_id,
'xui_panel_id': panel_id,
'password_hash': hash_password(req.password) if req.password else None,
'expires_at': None,
'duration_days': int(req.duration_days or 0),
@@ -4560,6 +4661,8 @@ async def api_update_invite(request: Request, invite_id: str, req: InviteUpdateR
link['server_id'] = int(req.server_id)
if req.xui_inbound_id is not None:
link['xui_inbound_id'] = int(req.xui_inbound_id)
if req.xui_panel_id is not None:
link['xui_panel_id'] = (req.xui_panel_id or '').strip()
if req.duration_days is not None:
if req.duration_days < 0:
return JSONResponse({'error': 'duration_days must be >= 0'}, status_code=400)
@@ -4698,6 +4801,7 @@ async def api_invite_create_config(token: str, req: InviteRedeemRequest, request
name=name,
server_id=int(link.get('server_id') or 0),
xui_inbound_id=int(link.get('xui_inbound_id') or 0) or None,
xui_panel_id=link.get('xui_panel_id') or None,
duration_days=int(link.get('duration_days') or 0),
)
conn = {
@@ -4707,6 +4811,7 @@ async def api_invite_create_config(token: str, req: InviteRedeemRequest, request
'protocol': created['protocol'],
'client_id': created['client_id'],
'name': name,
'xui_panel_id': created.get('xui_panel_id') or link.get('xui_panel_id') or '',
'created_at': datetime.now().isoformat(),
}
async with DATA_LOCK:
@@ -4759,9 +4864,17 @@ async def api_my_connection_config(request: Request, connection_id: str):
if protocol_base(conn.get('protocol', '')) == 'xui':
from managers.xui_api import xui_get_config
config = await xui_get_config(data.get('settings', {}), conn['client_id'])
vpn_link = generate_vpn_link(config) if config else ''
return {'config': config, 'vpn_link': vpn_link}
config = await xui_get_config(
data.get('settings', {}),
conn['client_id'],
panel_id=conn.get('xui_panel_id') or None,
)
vpn_link = config if str(config).startswith('http') else (generate_vpn_link(config) if config else '')
return {
'config': config,
'vpn_link': vpn_link,
'subscription_url': config if str(config).startswith('http') else '',
}
sid = conn['server_id']
if sid >= len(data['servers']):
@@ -4788,12 +4901,21 @@ async def settings_page(request: Request):
if not user:
return RedirectResponse('/login')
data = load_data()
from managers.xui_servers import list_xui_servers, public_server_view, ensure_xui_servers
settings = data.setdefault('settings', {})
before = json.dumps(settings.get('xui_servers') or [], sort_keys=True, default=str)
ensure_xui_servers(settings)
after = json.dumps(settings.get('xui_servers') or [], sort_keys=True, default=str)
if before != after:
save_data(data)
xui_servers = [public_server_view(s) for s in list_xui_servers(settings)]
return tpl(
request,
'settings.html',
settings=data.get('settings', {}),
servers=data.get('servers', []),
users=data.get('users', []),
xui_servers=xui_servers,
current_version=CURRENT_VERSION,
)
@@ -5050,20 +5172,88 @@ async def api_xui_sync_delete(request: Request):
@app.get('/api/settings/xui/inbounds', tags=["Settings"])
async def api_xui_list_inbounds(request: Request):
"""List VLESS inbounds from the configured 3x-ui panel."""
async def api_xui_list_inbounds(request: Request, panel_id: str = ''):
"""List VLESS inbounds from a 3x-ui panel (by panel_id or primary)."""
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
try:
data = load_data()
from managers.xui_api import xui_list_vless_inbounds
inbounds = await xui_list_vless_inbounds(data.get('settings', {}))
return {'inbounds': inbounds}
from managers.xui_servers import ensure_xui_servers, get_xui_server
ensure_xui_servers(data.setdefault('settings', {}))
panel = get_xui_server(data.get('settings') or {}, panel_id or None)
if not panel:
return JSONResponse({'error': 'No 3x-ui servers configured'}, status_code=400)
inbounds = await xui_list_vless_inbounds(data.get('settings', {}), panel_id=panel.get('id'))
return {
'inbounds': inbounds,
'panel_id': panel.get('id'),
'panel_name': panel.get('name'),
'sub_url': panel.get('sub_url') or '',
}
except Exception as e:
logger.exception("Error listing 3x-ui inbounds")
return JSONResponse({'error': str(e)}, status_code=500)
@app.get('/api/settings/xui/servers', tags=["Settings"])
async def api_xui_list_servers(request: Request):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
data = load_data()
from managers.xui_servers import list_xui_servers, public_server_view, ensure_xui_servers
ensure_xui_servers(data.setdefault('settings', {}))
servers = [public_server_view(s) for s in list_xui_servers(data.get('settings') or {})]
return {'servers': servers}
@app.post('/api/settings/xui/servers', tags=["Settings"])
async def api_xui_create_server(request: Request, req: XuiServerRequest):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
try:
data = load_data()
from managers.xui_servers import upsert_xui_server, public_server_view, ensure_xui_servers
ensure_xui_servers(data.setdefault('settings', {}))
server = upsert_xui_server(data['settings'], req.model_dump())
save_data(data)
return {'status': 'success', 'server': public_server_view(server)}
except Exception as e:
logger.exception('Error creating 3x-ui server')
return JSONResponse({'error': str(e)}, status_code=400)
@app.put('/api/settings/xui/servers/{panel_id}', tags=["Settings"])
async def api_xui_update_server(request: Request, panel_id: str, req: XuiServerRequest):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
try:
data = load_data()
from managers.xui_servers import upsert_xui_server, public_server_view, get_xui_server, ensure_xui_servers
ensure_xui_servers(data.setdefault('settings', {}))
if not get_xui_server(data.get('settings') or {}, panel_id):
return JSONResponse({'error': 'Server not found'}, status_code=404)
server = upsert_xui_server(data['settings'], req.model_dump(), panel_id=panel_id)
save_data(data)
return {'status': 'success', 'server': public_server_view(server)}
except Exception as e:
logger.exception('Error updating 3x-ui server')
return JSONResponse({'error': str(e)}, status_code=400)
@app.delete('/api/settings/xui/servers/{panel_id}', tags=["Settings"])
async def api_xui_delete_server(request: Request, panel_id: str):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
data = load_data()
from managers.xui_servers import delete_xui_server, ensure_xui_servers
ensure_xui_servers(data.setdefault('settings', {}))
if not delete_xui_server(data['settings'], panel_id):
return JSONResponse({'error': 'Server not found'}, status_code=404)
save_data(data)
return {'status': 'success'}
@app.get('/api/servers/{server_id}/{protocol}/clients', tags=["Connections"])
async def api_get_server_clients(request: Request, server_id: int, protocol: str):
if not _check_admin(request):