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):
+6
View File
@@ -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')
+2
View File
@@ -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
View File
@@ -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
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 {}}
+22 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+10
View File
@@ -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",
+10
View File
@@ -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": "ایجاد خودکار اتصال‌ها",
+10
View File
@@ -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",
+10
View File
@@ -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": "Создавать подключения автоматически",
+10
View File
@@ -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": "自动创建连接",