diff --git a/app.py b/app.py index a5b6616..713bd4e 100644 --- a/app.py +++ b/app.py @@ -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): diff --git a/db/connection.py b/db/connection.py index 7b57749..9d7cd86 100644 --- a/db/connection.py +++ b/db/connection.py @@ -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') diff --git a/db/schema.sql b/db/schema.sql index 272bd84..cb6c2ae 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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, diff --git a/db/store.py b/db/store.py index 320bedc..966100d 100644 --- a/db/store.py +++ b/db/store.py @@ -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), diff --git a/managers/xui_api.py b/managers/xui_api.py index 190f8ca..fdf2a7d 100644 --- a/managers/xui_api.py +++ b/managers/xui_api.py @@ -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() diff --git a/managers/xui_servers.py b/managers/xui_servers.py new file mode 100644 index 0000000..1c5b378 --- /dev/null +++ b/managers/xui_servers.py @@ -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 {}} diff --git a/templates/invites.html b/templates/invites.html index 56367b7..d955e3a 100644 --- a/templates/invites.html +++ b/templates/invites.html @@ -17,7 +17,7 @@ - {% if not xui_sub_url and xui_configured %} + {% if not xui_has_sub_url and xui_configured %}