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:
@@ -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):
|
||||
|
||||
@@ -87,6 +87,12 @@ def init_schema():
|
||||
cur.execute(
|
||||
"ALTER TABLE invite_links ADD COLUMN IF NOT EXISTS duration_days INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
cur.execute(
|
||||
"ALTER TABLE invite_links ADD COLUMN IF NOT EXISTS xui_panel_id TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
cur.execute(
|
||||
"ALTER TABLE user_connections ADD COLUMN IF NOT EXISTS xui_panel_id TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
conn.commit()
|
||||
_schema_ready = True
|
||||
logger.info('PostgreSQL schema ready')
|
||||
|
||||
@@ -42,6 +42,7 @@ CREATE TABLE IF NOT EXISTS user_connections (
|
||||
protocol TEXT NOT NULL DEFAULT '',
|
||||
client_id TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
xui_panel_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ,
|
||||
last_bytes BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
@@ -83,6 +84,7 @@ CREATE TABLE IF NOT EXISTS invite_links (
|
||||
protocol TEXT NOT NULL DEFAULT 'xui',
|
||||
server_id INTEGER NOT NULL DEFAULT 0,
|
||||
xui_inbound_id INTEGER NOT NULL DEFAULT 0,
|
||||
xui_panel_id TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT,
|
||||
expires_at TIMESTAMPTZ,
|
||||
duration_days INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
+18
-6
@@ -47,6 +47,7 @@ DEFAULT_SETTINGS = {
|
||||
'xui_inbound_id': 0,
|
||||
'xui_sub_url': '',
|
||||
},
|
||||
'xui_servers': [],
|
||||
'guest': {
|
||||
'enabled': False,
|
||||
'token': '',
|
||||
@@ -56,6 +57,7 @@ DEFAULT_SETTINGS = {
|
||||
'create_protocol': 'xui',
|
||||
'create_server_id': 0,
|
||||
'create_inbound_id': 0,
|
||||
'create_xui_panel_id': '',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -103,6 +105,12 @@ def _merge_settings(raw: Optional[dict]) -> dict:
|
||||
for key, value in raw.items():
|
||||
if key not in settings:
|
||||
settings[key] = value
|
||||
# Multi 3x-ui panels: migrate legacy sync.xui_* if needed
|
||||
try:
|
||||
from managers.xui_servers import ensure_xui_servers
|
||||
ensure_xui_servers(settings)
|
||||
except Exception:
|
||||
settings.setdefault('xui_servers', [])
|
||||
return settings
|
||||
|
||||
|
||||
@@ -179,6 +187,7 @@ def _row_to_connection(row) -> dict:
|
||||
'protocol': row['protocol'] or '',
|
||||
'client_id': row['client_id'] or '',
|
||||
'name': row['name'] or '',
|
||||
'xui_panel_id': (row.get('xui_panel_id') or '') if hasattr(row, 'get') else (row['xui_panel_id'] if 'xui_panel_id' in row else ''),
|
||||
'created_at': _ts_iso(row['created_at']),
|
||||
'last_bytes': int(row['last_bytes'] or 0),
|
||||
}
|
||||
@@ -208,6 +217,7 @@ def _row_to_invite(row) -> dict:
|
||||
'protocol': row['protocol'] or 'xui',
|
||||
'server_id': int(row['server_id'] or 0),
|
||||
'xui_inbound_id': int(row['xui_inbound_id'] or 0),
|
||||
'xui_panel_id': (row.get('xui_panel_id') or '') if hasattr(row, 'get') else '',
|
||||
'password_hash': row['password_hash'],
|
||||
'expires_at': _ts_iso(row['expires_at']),
|
||||
'duration_days': int(row.get('duration_days') or 0),
|
||||
@@ -239,7 +249,7 @@ def load_data() -> dict:
|
||||
|
||||
cur.execute(
|
||||
'SELECT id, user_id, server_id, protocol, client_id, name, '
|
||||
'created_at, last_bytes FROM user_connections '
|
||||
'xui_panel_id, created_at, last_bytes FROM user_connections '
|
||||
'ORDER BY created_at NULLS LAST, id'
|
||||
)
|
||||
user_connections = [_row_to_connection(r) for r in cur.fetchall()]
|
||||
@@ -253,7 +263,7 @@ def load_data() -> dict:
|
||||
|
||||
cur.execute(
|
||||
'SELECT id, name, token, enabled, max_uses, used_count, user_id, '
|
||||
'protocol, server_id, xui_inbound_id, password_hash, expires_at, '
|
||||
'protocol, server_id, xui_inbound_id, xui_panel_id, password_hash, expires_at, '
|
||||
'duration_days, note, created_at FROM invite_links '
|
||||
'ORDER BY created_at DESC NULLS LAST, name'
|
||||
)
|
||||
@@ -358,8 +368,8 @@ def save_data(data: dict) -> None:
|
||||
for conn_row in connections:
|
||||
cur.execute(
|
||||
'INSERT INTO user_connections ('
|
||||
'id, user_id, server_id, protocol, client_id, name, created_at, last_bytes'
|
||||
') VALUES (%s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
'id, user_id, server_id, protocol, client_id, name, xui_panel_id, created_at, last_bytes'
|
||||
') VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
(
|
||||
_as_uuid(conn_row['id']),
|
||||
_as_uuid(conn_row['user_id']),
|
||||
@@ -367,6 +377,7 @@ def save_data(data: dict) -> None:
|
||||
conn_row.get('protocol') or '',
|
||||
conn_row.get('client_id') or '',
|
||||
conn_row.get('name') or '',
|
||||
conn_row.get('xui_panel_id') or '',
|
||||
_parse_ts(conn_row.get('created_at')),
|
||||
int(conn_row.get('last_bytes') or 0),
|
||||
),
|
||||
@@ -393,9 +404,9 @@ def save_data(data: dict) -> None:
|
||||
cur.execute(
|
||||
'INSERT INTO invite_links ('
|
||||
'id, name, token, enabled, max_uses, used_count, user_id, '
|
||||
'protocol, server_id, xui_inbound_id, password_hash, expires_at, '
|
||||
'protocol, server_id, xui_inbound_id, xui_panel_id, password_hash, expires_at, '
|
||||
'duration_days, note, created_at'
|
||||
') VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
') VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
(
|
||||
_as_uuid(link['id']),
|
||||
link.get('name') or '',
|
||||
@@ -407,6 +418,7 @@ def save_data(data: dict) -> None:
|
||||
link.get('protocol') or 'xui',
|
||||
int(link.get('server_id') or 0),
|
||||
int(link.get('xui_inbound_id') or 0),
|
||||
link.get('xui_panel_id') or '',
|
||||
link.get('password_hash'),
|
||||
_parse_ts(link.get('expires_at')),
|
||||
int(link.get('duration_days') or 0),
|
||||
|
||||
+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 {}}
|
||||
+22
-3
@@ -17,7 +17,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if not xui_sub_url and xui_configured %}
|
||||
{% if not xui_has_sub_url and xui_configured %}
|
||||
<div class="card" style="margin-bottom:var(--space-lg); border-color: rgba(245, 158, 11, 0.35); background: rgba(245, 158, 11, 0.06);">
|
||||
<div style="display:flex; gap:var(--space-md); align-items:flex-start;">
|
||||
<div style="font-size:1.4rem;">⚠️</div>
|
||||
@@ -137,7 +137,14 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="inviteInboundGroup">
|
||||
<label class="form-label">{{ _('xui_inbound_label') }}</label>
|
||||
<label class="form-label">{{ _('xui_server_select_label') }}</label>
|
||||
<select class="form-select" id="inviteXuiPanel" onchange="loadInviteInbounds()">
|
||||
{% for s in xui_servers %}
|
||||
<option value="{{ s.id }}">{{ s.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-hint">{{ _('xui_server_select_hint') }}</div>
|
||||
<label class="form-label" style="margin-top:var(--space-sm);">{{ _('xui_inbound_label') }}</label>
|
||||
<select class="form-select" id="inviteInboundId">
|
||||
<option value="">{{ _('invite_inbound_loading') }}</option>
|
||||
</select>
|
||||
@@ -177,6 +184,7 @@
|
||||
const invitesData = {{ invites | tojson }};
|
||||
const xuiConfigured = {{ 'true' if xui_configured else 'false' }};
|
||||
const xuiDefaultInbound = {{ xui_default_inbound | int }};
|
||||
const xuiDefaultPanelId = {{ (xui_default_panel_id or '') | tojson }};
|
||||
|
||||
function inviteUrl(token) {
|
||||
return `${window.location.origin}/invite/${token}`;
|
||||
@@ -201,7 +209,9 @@
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await apiCall('/api/settings/xui/inbounds');
|
||||
const panelId = document.getElementById('inviteXuiPanel')?.value || xuiDefaultPanelId || '';
|
||||
const q = panelId ? `?panel_id=${encodeURIComponent(panelId)}` : '';
|
||||
const data = await apiCall('/api/settings/xui/inbounds' + q);
|
||||
const list = data.inbounds || [];
|
||||
if (!list.length) {
|
||||
select.innerHTML = `<option value="">${_('invite_inbound_empty')}</option>`;
|
||||
@@ -255,6 +265,10 @@
|
||||
document.getElementById('inviteClearPwdWrap').style.display = 'none';
|
||||
document.getElementById('inviteResetUsedWrap').style.display = 'none';
|
||||
setProtocolSelect('xui', 0);
|
||||
if (xuiDefaultPanelId) {
|
||||
const p = document.getElementById('inviteXuiPanel');
|
||||
if (p && [...p.options].some(o => o.value === xuiDefaultPanelId)) p.value = xuiDefaultPanelId;
|
||||
}
|
||||
loadInviteInbounds(xuiDefaultInbound);
|
||||
openModal('inviteModal');
|
||||
}
|
||||
@@ -275,6 +289,10 @@
|
||||
document.getElementById('inviteResetUsedWrap').style.display = 'block';
|
||||
document.getElementById('inviteResetUsed').checked = false;
|
||||
setProtocolSelect(inv.protocol || 'xui', inv.server_id || 0);
|
||||
const panelSel = document.getElementById('inviteXuiPanel');
|
||||
if (panelSel && inv.xui_panel_id && [...panelSel.options].some(o => o.value === inv.xui_panel_id)) {
|
||||
panelSel.value = inv.xui_panel_id;
|
||||
}
|
||||
loadInviteInbounds(inv.xui_inbound_id || xuiDefaultInbound);
|
||||
openModal('inviteModal');
|
||||
}
|
||||
@@ -298,6 +316,7 @@
|
||||
protocol: proto.protocol,
|
||||
server_id: proto.server_id,
|
||||
xui_inbound_id: inbound,
|
||||
xui_panel_id: document.getElementById('inviteXuiPanel')?.value || '',
|
||||
note: document.getElementById('inviteNote').value || '',
|
||||
};
|
||||
const pwd = document.getElementById('invitePassword').value;
|
||||
|
||||
+176
-52
@@ -126,7 +126,13 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="guestInboundGroup" style="{% if settings.guest.create_protocol != 'xui' %}display:none;{% endif %}">
|
||||
<label class="form-label">{{ _('xui_inbound_label') }}</label>
|
||||
<label class="form-label">{{ _('xui_server_select_label') }}</label>
|
||||
<select class="form-select" id="guest_create_xui_panel" onchange="loadXuiInbounds()">
|
||||
{% for s in xui_servers %}
|
||||
<option value="{{ s.id }}" {% if settings.guest.create_xui_panel_id == s.id %}selected{% endif %}>{{ s.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label class="form-label" style="margin-top:var(--space-sm);">{{ _('xui_inbound_label') }}</label>
|
||||
<select class="form-select" id="guest_create_inbound_id">
|
||||
<option value="0">{{ _('xui_inbound_auto') }}</option>
|
||||
</select>
|
||||
@@ -430,35 +436,8 @@
|
||||
|
||||
<div id="xuiFields"
|
||||
style="{% if not settings.sync.xui_sync %}display:none;{% endif %} border-top: 1px solid var(--border-color); padding-top: var(--space-md); margin-top: var(--space-md);">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('xui_url_label') }}</label>
|
||||
<input type="url" class="form-input" name="xui_url"
|
||||
value="{{ settings.sync.xui_url }}" placeholder="https://panel.example.com:2053/path">
|
||||
<div class="form-hint">{{ _('xui_url_hint') }}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('xui_sub_url_label') }}</label>
|
||||
<input type="url" class="form-input" name="xui_sub_url"
|
||||
value="{{ settings.sync.xui_sub_url or '' }}" placeholder="https://sub.example.com:2096/sub">
|
||||
<div class="form-hint">{{ _('xui_sub_url_hint') }}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('xui_api_token_label') }}</label>
|
||||
<input type="password" class="form-input" name="xui_api_token"
|
||||
value="{{ settings.sync.xui_api_token }}" placeholder="{{ _('xui_api_token_placeholder') }}">
|
||||
<div class="form-hint">{{ _('xui_api_token_hint') }}</div>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-sm);">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('xui_username_label') }}</label>
|
||||
<input type="text" class="form-input" name="xui_username"
|
||||
value="{{ settings.sync.xui_username }}" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('xui_password_label') }}</label>
|
||||
<input type="password" class="form-input" name="xui_password"
|
||||
value="{{ settings.sync.xui_password }}" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-hint" style="margin-bottom: var(--space-md);">
|
||||
{{ _('xui_servers_manage_hint') }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -491,14 +470,6 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('xui_inbound_label') }}</label>
|
||||
<select class="form-select" name="xui_inbound_id" id="xuiInboundSelect">
|
||||
<option value="0">{{ _('xui_inbound_auto') }}</option>
|
||||
</select>
|
||||
<div class="form-hint">{{ _('xui_inbound_hint') }}</div>
|
||||
</div>
|
||||
|
||||
<div id="xuiAutoConnFields"
|
||||
style="{% if not settings.sync.xui_create_conns %}display:none;{% endif %} background: var(--bg-primary); padding: var(--space-md); border-radius: var(--radius-md); margin-top: var(--space-sm);">
|
||||
<div class="form-group">
|
||||
@@ -519,10 +490,50 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- keep hidden legacy fields so saveSettings still posts them -->
|
||||
<input type="hidden" name="xui_url" value="{{ settings.sync.xui_url or '' }}">
|
||||
<input type="hidden" name="xui_sub_url" value="{{ settings.sync.xui_sub_url or '' }}">
|
||||
<input type="hidden" name="xui_api_token" value="{{ settings.sync.xui_api_token or '' }}">
|
||||
<input type="hidden" name="xui_username" value="{{ settings.sync.xui_username or '' }}">
|
||||
<input type="hidden" name="xui_password" value="{{ settings.sync.xui_password or '' }}">
|
||||
<input type="hidden" name="xui_inbound_id" value="{{ settings.sync.xui_inbound_id or 0 }}">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- BLOCK: 3x-ui servers (multi) -->
|
||||
<div class="card" style="margin-top: var(--space-lg);">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-lg);">
|
||||
<h3 class="card-title" style="margin:0;">{{ _('xui_servers_title') }}</h3>
|
||||
<button type="button" class="btn btn-primary btn-sm" onclick="openXuiServerModal()">+ {{ _('xui_server_add') }}</button>
|
||||
</div>
|
||||
<div class="form-hint" style="margin-bottom: var(--space-md);">{{ _('xui_servers_hint') }}</div>
|
||||
<div id="xuiServersList">
|
||||
{% if xui_servers %}
|
||||
{% for s in xui_servers %}
|
||||
<div class="client-item" style="margin-bottom:var(--space-sm);" data-xui-id="{{ s.id }}">
|
||||
<div class="client-info" style="flex:1; min-width:0;">
|
||||
<div class="client-avatar">🌀</div>
|
||||
<div style="min-width:0;">
|
||||
<div class="client-name">{{ s.name }}</div>
|
||||
<div class="client-meta" style="word-break:break-all;">
|
||||
<span>{{ s.url }}</span>
|
||||
{% if s.sub_url %}<span>· sub: {{ s.sub_url }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="client-actions">
|
||||
<button class="btn btn-secondary btn-sm" type="button" onclick='editXuiServer({{ s | tojson }})'>{{ _('edit') }}</button>
|
||||
<button class="btn btn-danger btn-sm" type="button" onclick="deleteXuiServer('{{ s.id }}')">{{ _('delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div style="text-align:center; padding:var(--space-lg); color:var(--text-muted);">{{ _('xui_servers_empty') }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BLOCK: Simple Backup -->
|
||||
<div class="card" style="margin-top: var(--space-lg);">
|
||||
<h3 class="card-title" style="margin-bottom: var(--space-lg);">📤 {{ _('backup_title') }}</h3>
|
||||
@@ -600,6 +611,66 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ===== 3x-ui Server Modal ===== -->
|
||||
<div class="modal-backdrop" id="xuiServerModal">
|
||||
<div class="modal" style="max-width:520px;">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="xuiServerModalTitle">{{ _('xui_server_add') }}</h2>
|
||||
<button class="modal-close" onclick="closeModal('xuiServerModal')">×</button>
|
||||
</div>
|
||||
<input type="hidden" id="xuiServerEditId" value="">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('xui_server_name_label') }}</label>
|
||||
<input class="form-input" type="text" id="xuiServerName" placeholder="US / NL / Home">
|
||||
<div class="form-hint">{{ _('xui_server_name_hint') }}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('xui_url_label') }}</label>
|
||||
<input class="form-input" type="url" id="xuiServerUrl" placeholder="https://panel.example.com:2053/path">
|
||||
<div class="form-hint">{{ _('xui_url_hint') }}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('xui_sub_url_label') }}</label>
|
||||
<input class="form-input" type="url" id="xuiServerSubUrl" placeholder="https://sub.example.com:2096/sub">
|
||||
<div class="form-hint">{{ _('xui_sub_url_hint') }}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('xui_api_token_label') }}</label>
|
||||
<input class="form-input" type="password" id="xuiServerToken" placeholder="{{ _('xui_api_token_placeholder') }}" autocomplete="off">
|
||||
<div class="form-hint">{{ _('xui_api_token_hint') }}</div>
|
||||
</div>
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-sm);">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('xui_username_label') }}</label>
|
||||
<input class="form-input" type="text" id="xuiServerUser" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('xui_password_label') }}</label>
|
||||
<input class="form-input" type="password" id="xuiServerPass" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex; align-items:center; gap:var(--space-sm); cursor:pointer;">
|
||||
<input type="checkbox" id="xuiServerEnabled" checked> {{ _('enabled') if False else 'Enabled' }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex; align-items:center; gap:var(--space-sm); cursor:pointer;">
|
||||
<input type="checkbox" id="xuiServerSyncUsers"> {{ _('enable_sync') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('xuiServerModal')">{{ _('cancel') }}</button>
|
||||
<button class="btn btn-primary" onclick="saveXuiServer()" id="xuiServerSaveBtn">
|
||||
<span id="xuiServerSaveText">{{ _('save') }}</span>
|
||||
<div class="spinner hidden" id="xuiServerSaveSpinner" style="width:14px;height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Create API Token Modal ===== -->
|
||||
<div class="modal-backdrop" id="createTokenModal">
|
||||
<div class="modal" style="max-width: 460px;">
|
||||
@@ -721,18 +792,77 @@
|
||||
|
||||
document.querySelector('[name="xui_sync"]').addEventListener('change', (e) => {
|
||||
document.getElementById('xuiFields').style.display = e.target.checked ? 'block' : 'none';
|
||||
if (e.target.checked) loadXuiInbounds();
|
||||
});
|
||||
|
||||
const xuiDefaultInboundId = {{ settings.sync.xui_inbound_id | default(0) | int }};
|
||||
function openXuiServerModal(server) {
|
||||
document.getElementById('xuiServerEditId').value = server?.id || '';
|
||||
document.getElementById('xuiServerModalTitle').textContent = server?.id ? (_('edit') + ' — 3x-ui') : _('xui_server_add');
|
||||
document.getElementById('xuiServerName').value = server?.name || '';
|
||||
document.getElementById('xuiServerUrl').value = server?.url || '';
|
||||
document.getElementById('xuiServerSubUrl').value = server?.sub_url || '';
|
||||
document.getElementById('xuiServerToken').value = '';
|
||||
document.getElementById('xuiServerUser').value = server?.username || '';
|
||||
document.getElementById('xuiServerPass').value = '';
|
||||
document.getElementById('xuiServerEnabled').checked = server ? !!server.enabled : true;
|
||||
document.getElementById('xuiServerSyncUsers').checked = !!(server && server.sync_users);
|
||||
openModal('xuiServerModal');
|
||||
}
|
||||
|
||||
function editXuiServer(server) { openXuiServerModal(server); }
|
||||
|
||||
async function saveXuiServer() {
|
||||
const id = document.getElementById('xuiServerEditId').value;
|
||||
const body = {
|
||||
name: document.getElementById('xuiServerName').value.trim(),
|
||||
url: document.getElementById('xuiServerUrl').value.trim(),
|
||||
sub_url: document.getElementById('xuiServerSubUrl').value.trim(),
|
||||
api_token: document.getElementById('xuiServerToken').value,
|
||||
username: document.getElementById('xuiServerUser').value.trim(),
|
||||
password: document.getElementById('xuiServerPass').value,
|
||||
enabled: document.getElementById('xuiServerEnabled').checked,
|
||||
sync_users: document.getElementById('xuiServerSyncUsers').checked,
|
||||
default_inbound_id: 0,
|
||||
};
|
||||
if (!body.url) { showToast(_('error') + ': URL', 'error'); return; }
|
||||
const btn = document.getElementById('xuiServerSaveBtn');
|
||||
const text = document.getElementById('xuiServerSaveText');
|
||||
const spinner = document.getElementById('xuiServerSaveSpinner');
|
||||
btn.disabled = true; text.textContent = _('saving') || 'Saving...'; spinner.classList.remove('hidden');
|
||||
try {
|
||||
if (id) await apiCall(`/api/settings/xui/servers/${encodeURIComponent(id)}`, 'PUT', body);
|
||||
else await apiCall('/api/settings/xui/servers', 'POST', body);
|
||||
showToast(_('saved') || 'OK', 'success');
|
||||
closeModal('xuiServerModal');
|
||||
setTimeout(() => location.reload(), 400);
|
||||
} catch (err) {
|
||||
showToast(_('error') + ': ' + err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false; text.textContent = _('save'); spinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteXuiServer(id) {
|
||||
if (!confirm(_('xui_server_delete_confirm') || 'Delete this 3x-ui server?')) return;
|
||||
try {
|
||||
await apiCall(`/api/settings/xui/servers/${encodeURIComponent(id)}`, 'DELETE');
|
||||
showToast(_('deleted') || 'OK', 'success');
|
||||
setTimeout(() => location.reload(), 400);
|
||||
} catch (err) {
|
||||
showToast(_('error') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Guest inbound loader still uses primary panel
|
||||
async function loadXuiInbounds() {
|
||||
const select = document.getElementById('xuiInboundSelect');
|
||||
const select = document.getElementById('guest_create_inbound_id') || document.getElementById('xuiInboundSelect');
|
||||
if (!select) return;
|
||||
const prev = select.value || String(xuiDefaultInboundId || '0');
|
||||
const prev = select.value || '0';
|
||||
select.innerHTML = `<option value="0">${_('xui_inbound_auto')}</option>`;
|
||||
try {
|
||||
const res = await fetch('/api/settings/xui/inbounds');
|
||||
const panelSel = document.getElementById('guest_create_xui_panel');
|
||||
const panelId = panelSel ? panelSel.value : '';
|
||||
const q = panelId ? `?panel_id=${encodeURIComponent(panelId)}` : '';
|
||||
const res = await fetch('/api/settings/xui/inbounds' + q);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to load inbounds');
|
||||
(data.inbounds || []).forEach(ib => {
|
||||
@@ -741,19 +871,12 @@
|
||||
opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
if ([...select.options].some(o => o.value === String(prev))) {
|
||||
select.value = String(prev);
|
||||
}
|
||||
if ([...select.options].some(o => o.value === String(prev))) select.value = String(prev);
|
||||
} catch (err) {
|
||||
// Keep auto option; credentials may not be saved yet
|
||||
console.warn('loadXuiInbounds:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.querySelector('[name="xui_sync"]').checked) {
|
||||
loadXuiInbounds();
|
||||
}
|
||||
|
||||
document.getElementById('syncCreateConns').addEventListener('change', (e) => {
|
||||
document.getElementById('autoConnFields').style.display = e.target.checked ? 'block' : 'none';
|
||||
if (e.target.checked) updateProtocolsForSync();
|
||||
@@ -1161,7 +1284,8 @@
|
||||
allow_create: document.getElementById('guest_allow_create').checked,
|
||||
create_protocol: createProtocol,
|
||||
create_server_id: createServerId,
|
||||
create_inbound_id: parseInt(document.getElementById('guest_create_inbound_id').value || '0')
|
||||
create_inbound_id: parseInt(document.getElementById('guest_create_inbound_id').value || '0'),
|
||||
create_xui_panel_id: document.getElementById('guest_create_xui_panel')?.value || '',
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
+12
-2
@@ -292,7 +292,13 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="ucXuiInboundGroup" style="display:none;">
|
||||
<label class="form-label">{{ _('xui_inbound_label') }}</label>
|
||||
<label class="form-label">{{ _('xui_server_select_label') }}</label>
|
||||
<select class="form-select" id="ucXuiPanel" onchange="loadXuiInbounds()">
|
||||
{% for s in xui_servers %}
|
||||
<option value="{{ s.id }}" {% if s.id == xui_default_panel_id %}selected{% endif %}>{{ s.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label class="form-label" style="margin-top:var(--space-sm);">{{ _('xui_inbound_label') }}</label>
|
||||
<select class="form-select" id="ucXuiInbound">
|
||||
<option value="">{{ _('xui_inbound_auto') }}</option>
|
||||
</select>
|
||||
@@ -407,6 +413,7 @@
|
||||
const serversData = {{ servers | tojson }};
|
||||
const xuiConfigured = {{ 'true' if xui_configured else 'false' }};
|
||||
const xuiDefaultInboundId = {{ xui_inbound_id | int }};
|
||||
const xuiServers = {{ xui_servers | default([]) | tojson }};
|
||||
|
||||
function populateTimeSelect(selectId) {
|
||||
const select = document.getElementById(selectId);
|
||||
@@ -640,7 +647,9 @@
|
||||
if (!select) return;
|
||||
select.innerHTML = `<option value="">${_('xui_inbound_auto')}</option>`;
|
||||
try {
|
||||
const data = await apiCall('/api/settings/xui/inbounds');
|
||||
const panelId = document.getElementById('ucXuiPanel')?.value || '';
|
||||
const q = panelId ? `?panel_id=${encodeURIComponent(panelId)}` : '';
|
||||
const data = await apiCall('/api/settings/xui/inbounds' + q);
|
||||
(data.inbounds || []).forEach(ib => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = ib.id;
|
||||
@@ -865,6 +874,7 @@
|
||||
} else if (body.protocol === 'xui') {
|
||||
const inbound = document.getElementById('ucXuiInbound').value;
|
||||
if (inbound) body.xui_inbound_id = parseInt(inbound);
|
||||
body.xui_panel_id = document.getElementById('ucXuiPanel')?.value || '';
|
||||
} else if (body.protocol === 'telemt') {
|
||||
body.telemt_quota = document.getElementById('ucTelemtQuota').value || null;
|
||||
body.telemt_max_ips = parseInt(document.getElementById('ucTelemtMaxIps').value) || null;
|
||||
|
||||
@@ -302,6 +302,16 @@
|
||||
"xui_inbound_label": "VLESS inbound",
|
||||
"xui_inbound_auto": "Auto (first VLESS inbound)",
|
||||
"xui_inbound_hint": "Used when creating 3x-ui VLESS configs. Create a VLESS inbound in 3x-ui first.",
|
||||
"xui_servers_title": "3x-ui servers",
|
||||
"xui_servers_hint": "Add multiple 3x-ui panels. When creating invites/configs, pick a server by name — its subscription base URL is used.",
|
||||
"xui_servers_empty": "No 3x-ui servers yet. Click Add server.",
|
||||
"xui_servers_manage_hint": "Panel URLs and subscription bases are managed in the “3x-ui servers” section below.",
|
||||
"xui_server_add": "Add server",
|
||||
"xui_server_name_label": "Name / description",
|
||||
"xui_server_name_hint": "Shown in invites and user forms (e.g. US, NL, Home).",
|
||||
"xui_server_select_label": "3x-ui server",
|
||||
"xui_server_select_hint": "Choose which 3x-ui panel (by description) will issue the subscription URL.",
|
||||
"xui_server_delete_confirm": "Delete this 3x-ui server from the panel?",
|
||||
"sync_now_btn": "🔄 Sync now",
|
||||
"delete_sync_btn": "🗑 Delete synchronization",
|
||||
"auto_create_conns": "Create connections automatically",
|
||||
|
||||
@@ -300,6 +300,16 @@
|
||||
"xui_inbound_label": "ورودی VLESS",
|
||||
"xui_inbound_auto": "خودکار (اولین ورودی VLESS)",
|
||||
"xui_inbound_hint": "برای ساخت کانفیگ 3x-ui VLESS استفاده میشود. ابتدا یک ورودی VLESS در 3x-ui بسازید.",
|
||||
"xui_servers_title": "سرورهای 3x-ui",
|
||||
"xui_servers_hint": "چند پنل 3x-ui اضافه کنید. هنگام ساخت، سرور را با نام انتخاب کنید تا URL اشتراک همان سرور داده شود.",
|
||||
"xui_servers_empty": "هنوز سرور 3x-ui نیست. افزودن را بزنید.",
|
||||
"xui_servers_manage_hint": "آدرس پنل و پایه اشتراک در بخش «سرورهای 3x-ui» مدیریت میشود.",
|
||||
"xui_server_add": "افزودن سرور",
|
||||
"xui_server_name_label": "نام / توضیح",
|
||||
"xui_server_name_hint": "در دعوتها نمایش داده میشود (مثل US، NL).",
|
||||
"xui_server_select_label": "سرور 3x-ui",
|
||||
"xui_server_select_hint": "پنل 3x-ui که URL اشتراک میدهد را انتخاب کنید.",
|
||||
"xui_server_delete_confirm": "این سرور 3x-ui حذف شود؟",
|
||||
"sync_now_btn": "🔄 همگامسازی اکنون",
|
||||
"delete_sync_btn": "🗑 حذف همگامسازی",
|
||||
"auto_create_conns": "ایجاد خودکار اتصالها",
|
||||
|
||||
@@ -300,6 +300,16 @@
|
||||
"xui_inbound_label": "Inbound VLESS",
|
||||
"xui_inbound_auto": "Auto (premier inbound VLESS)",
|
||||
"xui_inbound_hint": "Utilisé pour créer des configs 3x-ui VLESS. Créez d'abord un inbound VLESS dans 3x-ui.",
|
||||
"xui_servers_title": "Serveurs 3x-ui",
|
||||
"xui_servers_hint": "Ajoutez plusieurs panneaux 3x-ui. À la création, choisissez le serveur par nom — son URL d'abonnement est utilisée.",
|
||||
"xui_servers_empty": "Aucun serveur 3x-ui. Cliquez sur Ajouter.",
|
||||
"xui_servers_manage_hint": "Les URL et bases d'abonnement se gèrent dans la section « Serveurs 3x-ui » ci-dessous.",
|
||||
"xui_server_add": "Ajouter un serveur",
|
||||
"xui_server_name_label": "Nom / description",
|
||||
"xui_server_name_hint": "Affiché dans les invitations (ex: US, NL).",
|
||||
"xui_server_select_label": "Serveur 3x-ui",
|
||||
"xui_server_select_hint": "Choisissez le panneau 3x-ui qui émettra l'URL d'abonnement.",
|
||||
"xui_server_delete_confirm": "Supprimer ce serveur 3x-ui ?",
|
||||
"sync_now_btn": "🔄 Sync maintenant",
|
||||
"delete_sync_btn": "🗑 Supprimer synchro",
|
||||
"auto_create_conns": "Créer connexions auto",
|
||||
|
||||
@@ -302,6 +302,16 @@
|
||||
"xui_inbound_label": "VLESS inbound",
|
||||
"xui_inbound_auto": "Авто (первый VLESS inbound)",
|
||||
"xui_inbound_hint": "Используется при создании конфигов 3x-ui VLESS. Сначала создайте VLESS inbound в 3x-ui.",
|
||||
"xui_servers_title": "Серверы 3x-ui",
|
||||
"xui_servers_hint": "Можно добавить несколько панелей 3x-ui. При создании инвайта/конфига выберите сервер по описанию — будет выдан URL подписки этого сервера.",
|
||||
"xui_servers_empty": "Серверов 3x-ui пока нет. Нажмите «Добавить сервер».",
|
||||
"xui_servers_manage_hint": "URL панелей и базовые URL подписок настраиваются в разделе «Серверы 3x-ui» ниже.",
|
||||
"xui_server_add": "Добавить сервер",
|
||||
"xui_server_name_label": "Название / описание",
|
||||
"xui_server_name_hint": "Так сервер будет отображаться в инвайтах и у пользователей (например: US, NL, Home).",
|
||||
"xui_server_select_label": "Сервер 3x-ui",
|
||||
"xui_server_select_hint": "Выберите, с какой панели 3x-ui выдавать URL подписки.",
|
||||
"xui_server_delete_confirm": "Удалить этот сервер 3x-ui из панели?",
|
||||
"sync_now_btn": "🔄 Синхронизировать сейчас",
|
||||
"delete_sync_btn": "🗑 Удалить синхронизацию",
|
||||
"auto_create_conns": "Создавать подключения автоматически",
|
||||
|
||||
@@ -300,6 +300,16 @@
|
||||
"xui_inbound_label": "VLESS 入站",
|
||||
"xui_inbound_auto": "自动(第一个 VLESS 入站)",
|
||||
"xui_inbound_hint": "创建 3x-ui VLESS 配置时使用。请先在 3x-ui 中创建 VLESS 入站。",
|
||||
"xui_servers_title": "3x-ui 服务器",
|
||||
"xui_servers_hint": "可添加多个 3x-ui 面板。创建配置时按名称选择服务器,并使用其订阅基础 URL。",
|
||||
"xui_servers_empty": "暂无 3x-ui 服务器。请点击添加。",
|
||||
"xui_servers_manage_hint": "面板与订阅地址在下方「3x-ui 服务器」中管理。",
|
||||
"xui_server_add": "添加服务器",
|
||||
"xui_server_name_label": "名称 / 描述",
|
||||
"xui_server_name_hint": "显示在邀请与用户表单中(如 US、NL)。",
|
||||
"xui_server_select_label": "3x-ui 服务器",
|
||||
"xui_server_select_hint": "选择签发订阅 URL 的 3x-ui 面板。",
|
||||
"xui_server_delete_confirm": "从面板删除此 3x-ui 服务器?",
|
||||
"sync_now_btn": "🔄 立即同步",
|
||||
"delete_sync_btn": "🗑 删除同步数据",
|
||||
"auto_create_conns": "自动创建连接",
|
||||
|
||||
Reference in New Issue
Block a user