diff --git a/app.py b/app.py index 7e9bdfa..c06cf76 100644 --- a/app.py +++ b/app.py @@ -964,7 +964,7 @@ def _manager_call(manager, method, protocol, *args, **kwargs): # Protocols that own VPN clients (can list/link connections) -CLIENT_VPN_BASES = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard'} +CLIENT_VPN_BASES = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard', 'xui'} def generate_vpn_link(config_text): @@ -1050,10 +1050,14 @@ async def perform_delete_user(data: dict, user_id: str): user = next((u for u in data['users'] if u['id'] == user_id), None) if not user: return False - # Remove user's connections from servers + # Remove user's connections from servers / 3x-ui user_conns = [c for c in data.get('user_connections', []) if c['user_id'] == user_id] for uc in user_conns: try: + if protocol_base(uc.get('protocol', '')) == 'xui': + from managers.xui_api import xui_delete_client + await xui_delete_client(data.get('settings', {}), uc['client_id']) + continue sid = uc['server_id'] if sid < len(data['servers']): server = data['servers'][sid] @@ -1080,6 +1084,10 @@ async def perform_toggle_user(data: dict, user_id: str, enable: bool) -> bool: user_conns = [c for c in data.get('user_connections', []) if c['user_id'] == user_id] for uc in user_conns: try: + if protocol_base(uc.get('protocol', '')) == 'xui': + from managers.xui_api import xui_toggle_client + await xui_toggle_client(data.get('settings', {}), uc['client_id'], enable) + continue sid = uc['server_id'] if sid >= len(data['servers']): continue @@ -1699,6 +1707,7 @@ class AddConnectionRequest(BaseModel): telemt_secret: Optional[str] = None telemt_ad_tag: Optional[str] = None telemt_max_conns: Optional[int] = None + xui_inbound_id: Optional[int] = None class EditConnectionRequest(BaseModel): @@ -1783,6 +1792,7 @@ class SyncSettings(BaseModel): xui_create_conns: bool = False xui_server_id: int = 0 xui_protocol: str = 'xray' + xui_inbound_id: int = 0 class CaptchaSettings(BaseModel): enabled: bool = False @@ -1802,6 +1812,16 @@ class TelegramSettings(BaseModel): enabled: bool = False +class GuestSettings(BaseModel): + enabled: bool = False + token: str = '' + password: str = '' # plaintext from form; empty = keep existing hash + clear_password: bool = False + user_id: str = '' + allow_create: bool = False + create_protocol: str = 'xui' + create_server_id: int = 0 + create_inbound_id: int = 0 class UpdateUserRequest(BaseModel): @@ -1821,6 +1841,7 @@ class SaveSettingsRequest(BaseModel): captcha: CaptchaSettings telegram: TelegramSettings ssl: SSLSettings + guest: GuestSettings = GuestSettings() class ToggleUserRequest(BaseModel): @@ -1828,7 +1849,7 @@ class ToggleUserRequest(BaseModel): class AddUserConnectionRequest(BaseModel): - server_id: int + server_id: int = 0 protocol: str = 'awg' name: str = 'VPN Connection' client_id: Optional[str] = None @@ -1838,6 +1859,7 @@ class AddUserConnectionRequest(BaseModel): telemt_secret: Optional[str] = None telemt_ad_tag: Optional[str] = None telemt_max_conns: Optional[int] = None + xui_inbound_id: Optional[int] = None class CreateApiTokenRequest(BaseModel): @@ -1853,6 +1875,10 @@ class ShareAuthRequest(BaseModel): password: str +class GuestCreateRequest(BaseModel): + name: str = 'Guest VPN' + + class TunnelStartRequest(BaseModel): authtoken: Optional[str] = None @@ -2107,7 +2133,12 @@ async def periodic_background_tasks(): async def login_page(request: Request): if get_current_user(request): return RedirectResponse(url='/', status_code=302) - return tpl(request, 'login.html') + data = load_data() + guest = data.get('settings', {}).get('guest') or {} + guest_link = None + if guest.get('enabled') and guest.get('token'): + guest_link = f"/guest/{guest['token']}" + return tpl(request, 'login.html', guest_link=guest_link) @app.get("/set_lang/{lang}", tags=["System Templates"]) @@ -2164,7 +2195,15 @@ 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'] - return tpl(request, 'users.html', users=users_list, servers=servers) + sync = data.get('settings', {}).get('sync', {}) + xui_configured = bool((sync.get('xui_url') or '').strip()) + return tpl( + request, 'users.html', + users=users_list, + servers=servers, + xui_configured=xui_configured, + xui_inbound_id=sync.get('xui_inbound_id') or 0, + ) @app.get('/my', response_class=HTMLResponse, tags=["System Templates"]) @@ -2176,6 +2215,9 @@ async def my_connections_page(request: Request): conns = [c for c in data.get('user_connections', []) if c['user_id'] == user['id']] # Enrich with server names for c in conns: + if protocol_base(c.get('protocol', '')) == 'xui': + c['server_name'] = '3x-ui' + continue sid = c.get('server_id', 0) if sid < len(data['servers']): c['server_name'] = data['servers'][sid].get('name', data['servers'][sid].get('host', '')) @@ -3235,6 +3277,31 @@ async def api_add_connection(request: Request, server_id: int, req: AddConnectio data = load_data() if server_id >= len(data['servers']): return JSONResponse({'error': 'Server not found'}, status_code=404) + + if protocol_base(req.protocol) == 'xui': + from managers.xui_api import xui_create_vless_config + created = await xui_create_vless_config( + data.get('settings', {}), + name=req.name, + inbound_id=req.xui_inbound_id, + ) + result = {'client_id': created['client_id'], 'config': created.get('config') or ''} + if result.get('config'): + result['vpn_link'] = generate_vpn_link(result['config']) + if req.user_id and result.get('client_id'): + conn = { + 'id': str(uuid.uuid4()), + 'user_id': req.user_id, + 'server_id': server_id, + 'protocol': 'xui', + 'client_id': result['client_id'], + 'name': req.name, + 'created_at': datetime.now().isoformat(), + } + data['user_connections'].append(conn) + save_data(data) + return result + server = data['servers'][server_id] proto_info = server.get('protocols', {}).get(req.protocol, {}) port = proto_info.get('port', '55424') @@ -3287,20 +3354,26 @@ async def api_remove_connection(request: Request, server_id: int, req: Connectio return JSONResponse({'error': 'Forbidden'}, status_code=403) try: data = load_data() - if server_id >= len(data['servers']): - return JSONResponse({'error': 'Server not found'}, status_code=404) - server = data['servers'][server_id] if not req.client_id: return JSONResponse({'error': 'Client ID is required'}, status_code=400) - ssh = get_ssh(server) - ssh.connect() - manager = get_protocol_manager(ssh, req.protocol) - _manager_call(manager, 'remove_client', req.protocol, req.client_id) - ssh.disconnect() + + if protocol_base(req.protocol) == 'xui': + from managers.xui_api import xui_delete_client + await xui_delete_client(data.get('settings', {}), req.client_id) + else: + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, req.protocol) + _manager_call(manager, 'remove_client', req.protocol, req.client_id) + ssh.disconnect() # Remove from user_connections data['user_connections'] = [ c for c in data.get('user_connections', []) - if not (c.get('client_id') == req.client_id and c.get('server_id') == server_id) + if not (c.get('client_id') == req.client_id and c.get('server_id') == server_id + and c.get('protocol') == req.protocol) ] save_data(data) return {'status': 'success'} @@ -3347,8 +3420,8 @@ async def api_get_connection_config(request: Request, server_id: int, req: Conne return JSONResponse({'error': 'Forbidden'}, status_code=403) try: data = load_data() - if server_id >= len(data['servers']): - return JSONResponse({'error': 'Server not found'}, status_code=404) + if not req.client_id: + return JSONResponse({'error': 'Client ID is required'}, status_code=400) # Users can only view their own connections if user['role'] == 'user': owned = any( @@ -3357,9 +3430,16 @@ async def api_get_connection_config(request: Request, server_id: int, req: Conne ) if not owned: return JSONResponse({'error': 'Forbidden'}, status_code=403) + + if protocol_base(req.protocol) == 'xui': + from managers.xui_api import xui_get_config + config = await xui_get_config(data.get('settings', {}), req.client_id) + vpn_link = generate_vpn_link(config) if config else '' + return {'config': config, 'vpn_link': vpn_link} + + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) server = data['servers'][server_id] - if not req.client_id: - return JSONResponse({'error': 'Client ID is required'}, status_code=400) proto_info = server.get('protocols', {}).get(req.protocol, {}) port = proto_info.get('port', '55424') ssh = get_ssh(server) @@ -3380,18 +3460,21 @@ async def api_toggle_connection(request: Request, server_id: int, req: ToggleCon return JSONResponse({'error': 'Forbidden'}, status_code=403) try: data = load_data() + if not req.client_id: + return JSONResponse({'error': 'Client ID is required'}, status_code=400) + if protocol_base(req.protocol) == 'xui': + from managers.xui_api import xui_toggle_client + await xui_toggle_client(data.get('settings', {}), req.client_id, req.enable) + return {'status': 'success'} if server_id >= len(data['servers']): return JSONResponse({'error': 'Server not found'}, status_code=404) server = data['servers'][server_id] - if not req.client_id: - return JSONResponse({'error': 'Client ID is required'}, status_code=400) ssh = get_ssh(server) ssh.connect() manager = get_protocol_manager(ssh, req.protocol) _manager_call(manager, 'toggle_client', req.protocol, req.client_id, req.enable) ssh.disconnect() - status = 'enabled' if req.enable else 'disabled' - return {'status': 'success', 'enabled': req.enable, 'message': f'Connection {status}'} + return {'status': 'success'} except Exception as e: logger.exception("Error toggling connection") return JSONResponse({'error': str(e)}, status_code=500) @@ -3632,13 +3715,49 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo user = next((u for u in data['users'] if u['id'] == user_id), None) if not user: return JSONResponse({'error': 'User not found'}, status_code=404) - if req.server_id >= len(data['servers']): - return JSONResponse({'error': 'Server not found'}, status_code=404) if protocol_base(req.protocol) not in CLIENT_VPN_BASES: return JSONResponse( {'error': f'Protocol "{req.protocol}" does not support user connections'}, status_code=400, ) + + # 3x-ui VLESS — no SSH; uses panel settings credentials + if protocol_base(req.protocol) == 'xui': + 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} + 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, + ) + result = {'client_id': created['client_id'], 'config': created.get('config') or ''} + sid = req.server_id if data['servers'] and req.server_id < len(data['servers']) else 0 + if result.get('client_id'): + conn = { + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'server_id': sid, + 'protocol': 'xui', + 'client_id': result['client_id'], + 'name': req.name, + 'created_at': datetime.now().isoformat(), + } + async with DATA_LOCK: + data = load_data() + data['user_connections'].append(conn) + save_data(data) + resp = {'status': 'success'} + if result.get('config'): + resp['config'] = result['config'] + resp['vpn_link'] = generate_vpn_link(result['config']) + return resp + + if req.server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) server = data['servers'][req.server_id] proto_info = server.get('protocols', {}).get(req.protocol, {}) port = proto_info.get('port', '55424') @@ -3710,6 +3829,9 @@ async def api_get_user_connections(request: Request, user_id: str): data = load_data() conns = [c for c in data.get('user_connections', []) if c['user_id'] == user_id] for c in conns: + if protocol_base(c.get('protocol', '')) == 'xui': + c['server_name'] = '3x-ui' + continue sid = c.get('server_id', 0) if sid < len(data['servers']): c['server_name'] = data['servers'][sid].get('name', '') @@ -3726,6 +3848,9 @@ async def api_my_connections(request: Request): data = load_data() conns = [c for c in data.get('user_connections', []) if c['user_id'] == user['id']] for c in conns: + if protocol_base(c.get('protocol', '')) == 'xui': + c['server_name'] = '3x-ui' + continue sid = c.get('server_id', 0) if sid < len(data['servers']): c['server_name'] = data['servers'][sid].get('name', '') @@ -3825,6 +3950,12 @@ async def api_share_config(token: str, connection_id: str, request: Request): return JSONResponse({'error': 'Not found'}, status_code=404) 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} + sid = conn['server_id'] server = data['servers'][sid] proto_info = server.get('protocols', {}).get(conn['protocol'], {}) @@ -3842,6 +3973,240 @@ async def api_share_config(token: str, connection_id: str, request: Request): return JSONResponse({'error': str(e)}, status_code=500) +# ======================== Guest access (no registration) ======================== + +def _guest_settings(data: Optional[dict] = None) -> dict: + if data is None: + data = load_data() + guest = dict((data.get('settings') or {}).get('guest') or {}) + defaults = { + 'enabled': False, + 'token': '', + 'password_hash': None, + 'user_id': '', + 'allow_create': False, + 'create_protocol': 'xui', + 'create_server_id': 0, + 'create_inbound_id': 0, + } + defaults.update(guest) + return defaults + + +def _resolve_guest(token: str, request: Request): + """Return (data, guest_cfg, holder_user, error_response).""" + data = load_data() + guest = _guest_settings(data) + if not guest.get('enabled') or not guest.get('token') or guest.get('token') != token: + return data, guest, None, JSONResponse({'error': 'Forbidden'}, status_code=403) + if guest.get('password_hash') and not request.session.get(f'guest_auth_{token}'): + return data, guest, None, JSONResponse({'error': 'Unauthorized'}, status_code=401) + holder = None + uid = guest.get('user_id') or '' + if uid: + holder = next((u for u in data['users'] if u['id'] == uid), None) + return data, guest, holder, None + + +def _enrich_guest_conn(c: dict, data: dict) -> dict: + out = dict(c) + if protocol_base(out.get('protocol', '')) == 'xui': + out['server_name'] = '3x-ui' + else: + sid = out.get('server_id', 0) + if sid < len(data['servers']): + out['server_name'] = data['servers'][sid].get('name') or data['servers'][sid]['host'] + else: + out['server_name'] = 'Unknown' + return out + + +@app.get('/guest/{token}', response_class=HTMLResponse, tags=["System Templates"]) +async def guest_page(token: str, request: Request): + data = load_data() + guest = _guest_settings(data) + lang = request.cookies.get('lang', 'ru') + if not guest.get('enabled') or guest.get('token') != token: + return HTMLResponse( + f"
{_t('guest_not_found_desc', lang)}
", + status_code=404, + ) + need_password = bool(guest.get('password_hash')) and not request.session.get(f'guest_auth_{token}') + return tpl( + request, + 'guest.html', + need_password=need_password, + token=token, + allow_create=bool(guest.get('allow_create')), + create_protocol=guest.get('create_protocol') or 'xui', + ) + + +@app.post('/api/guest/{token}/auth', tags=["Guest"]) +async def api_guest_auth(token: str, req: ShareAuthRequest, request: Request): + data = load_data() + guest = _guest_settings(data) + if not guest.get('enabled') or guest.get('token') != token: + return JSONResponse({'error': 'Link expired or disabled'}, status_code=404) + if not guest.get('password_hash'): + request.session[f'guest_auth_{token}'] = True + return {'status': 'success'} + if verify_password(req.password, guest.get('password_hash') or ''): + request.session[f'guest_auth_{token}'] = True + return {'status': 'success'} + lang = request.cookies.get('lang', 'ru') + return JSONResponse({'error': _t('wrong_guest_password', lang)}, status_code=401) + + +@app.get('/api/guest/{token}/connections', tags=["Guest"]) +async def api_guest_connections(token: str, request: Request): + data, guest, holder, err = _resolve_guest(token, request) + if err: + return err + if not holder: + return { + 'connections': [], + 'allow_create': bool(guest.get('allow_create')), + 'create_protocol': guest.get('create_protocol') or 'xui', + } + conns = [ + _enrich_guest_conn(c, data) + for c in data.get('user_connections', []) + if c['user_id'] == holder['id'] + ] + return { + 'connections': conns, + 'allow_create': bool(guest.get('allow_create')), + 'create_protocol': guest.get('create_protocol') or 'xui', + } + + +@app.post('/api/guest/{token}/config/{connection_id}', tags=["Guest"]) +async def api_guest_config(token: str, connection_id: str, request: Request): + data, guest, holder, err = _resolve_guest(token, request) + if err: + return err + if not holder: + return JSONResponse({'error': 'Not found'}, status_code=404) + conn = next( + (c for c in data.get('user_connections', []) if c['id'] == connection_id and c['user_id'] == holder['id']), + None, + ) + if not conn: + return JSONResponse({'error': 'Not found'}, status_code=404) + 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} + + sid = conn['server_id'] + server = data['servers'][sid] + proto_info = server.get('protocols', {}).get(conn['protocol'], {}) + port = proto_info.get('port', '55424') + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, conn['protocol']) + config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port) + ssh.disconnect() + vpn_link = generate_vpn_link(config) if config else '' + return {'config': config, 'vpn_link': vpn_link} + except Exception as e: + logger.exception("Error getting guest config") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/guest/{token}/create', tags=["Guest"]) +async def api_guest_create(token: str, req: GuestCreateRequest, request: Request): + """Create a new VPN config for a guest (no registration).""" + data, guest, holder, err = _resolve_guest(token, request) + if err: + return err + if not guest.get('allow_create'): + return JSONResponse({'error': 'Guest config creation is disabled'}, status_code=403) + if not holder: + return JSONResponse({'error': 'Guest user is not configured in settings'}, status_code=400) + + protocol = guest.get('create_protocol') or 'xui' + name = (req.name or 'Guest VPN').strip() or 'Guest VPN' + # Unique-ish name to avoid collisions + name = f"{name}_{secrets.token_hex(3)}" + + try: + if protocol_base(protocol) == 'xui': + from managers.xui_api import xui_create_vless_config + created = await xui_create_vless_config( + data.get('settings', {}), + name=name, + inbound_id=guest.get('create_inbound_id') or None, + ) + result = {'client_id': created['client_id'], 'config': created.get('config') or ''} + sid = 0 + protocol = 'xui' + else: + sid = int(guest.get('create_server_id') or 0) + if sid >= len(data['servers']): + return JSONResponse({'error': 'Guest server not found'}, status_code=400) + server = data['servers'][sid] + proto_info = server.get('protocols', {}).get(protocol, {}) + port = proto_info.get('port', '55424') + ssh = get_ssh(server) + await asyncio.to_thread(ssh.connect) + try: + manager = get_protocol_manager(ssh, protocol) + if protocol_base(protocol) == 'wireguard': + result = await asyncio.to_thread(manager.add_client, name, server['host']) + else: + result = await asyncio.to_thread( + _manager_call, manager, 'add_client', + protocol, name, server['host'], port, + ) + finally: + await asyncio.to_thread(ssh.disconnect) + + if not result.get('client_id'): + return JSONResponse({'error': 'Failed to create config'}, status_code=500) + + conn = { + 'id': str(uuid.uuid4()), + 'user_id': holder['id'], + 'server_id': sid, + 'protocol': protocol, + 'client_id': result['client_id'], + 'name': name, + 'created_at': datetime.now().isoformat(), + } + async with DATA_LOCK: + data = load_data() + data['user_connections'].append(conn) + save_data(data) + + config = result.get('config') or '' + vpn_link = generate_vpn_link(config) if config else '' + return { + 'status': 'success', + 'connection': _enrich_guest_conn(conn, data), + 'config': config, + 'vpn_link': vpn_link, + } + except Exception as e: + logger.exception("Error creating guest config") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/settings/guest/regenerate_token', tags=["Settings"]) +async def api_guest_regenerate_token(request: Request): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + data = load_data() + guest = _guest_settings(data) + guest['token'] = secrets.token_urlsafe(16) + data.setdefault('settings', {})['guest'] = guest + save_data(data) + return {'status': 'success', 'token': guest['token']} + + @app.post('/api/my/connections/{connection_id}/config', tags=["Self-service"]) async def api_my_connection_config(request: Request, connection_id: str): user = get_current_user(request) @@ -3855,6 +4220,13 @@ async def api_my_connection_config(request: Request, connection_id: str): ) if not conn: return JSONResponse({'error': 'Connection not found'}, status_code=404) + + 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} + sid = conn['server_id'] if sid >= len(data['servers']): return JSONResponse({'error': 'Server not found'}, status_code=404) @@ -3880,7 +4252,14 @@ async def settings_page(request: Request): if not user: return RedirectResponse('/login') data = load_data() - return tpl(request, 'settings.html', settings=data.get('settings', {}), servers=data.get('servers', []), current_version=CURRENT_VERSION) + return tpl( + request, + 'settings.html', + settings=data.get('settings', {}), + servers=data.get('servers', []), + users=data.get('users', []), + current_version=CURRENT_VERSION, + ) @app.get('/api/settings', tags=["Settings"]) @@ -4009,6 +4388,23 @@ async def save_settings(request: Request, payload: SaveSettingsRequest): data['settings']['captcha'] = payload.captcha.dict() data['settings']['telegram'] = payload.telegram.dict() data['settings']['ssl'] = payload.ssl.dict() + + existing_guest = dict(data.get('settings', {}).get('guest') or {}) + guest = payload.guest.dict() + password = (guest.pop('password', None) or '').strip() + clear_password = bool(guest.pop('clear_password', False)) + token = (guest.get('token') or existing_guest.get('token') or '').strip() + if guest.get('enabled') and not token: + token = secrets.token_urlsafe(16) + guest['token'] = token + if clear_password: + guest['password_hash'] = None + elif password: + guest['password_hash'] = hash_password(password) + else: + guest['password_hash'] = existing_guest.get('password_hash') + data['settings']['guest'] = guest + save_data(data) logger.info("Settings saved (including captcha and telegram)") @@ -4023,7 +4419,7 @@ async def save_settings(request: Request, payload: SaveSettingsRequest): logger.info("Stopping Telegram bot (settings save)...") asyncio.create_task(tg_bot.stop_bot()) - return {"status": "success", "bot_running": tg_bot.is_running()} + return {"status": "success", "bot_running": tg_bot.is_running(), "guest_token": token} @app.post('/api/settings/telegram/toggle', tags=["Settings"]) @@ -4086,6 +4482,7 @@ async def api_xui_sync_now(request: Request): for key in ( 'xui_url', 'xui_username', 'xui_password', 'xui_api_token', 'xui_create_conns', 'xui_server_id', 'xui_protocol', 'xui_sync_users', 'xui_sync', + 'xui_inbound_id', ): if key in body and body[key] is not None: sync_cfg[key] = body[key] @@ -4116,6 +4513,21 @@ async def api_xui_sync_delete(request: Request): return {'status': 'success', 'count': len(to_delete_ids)} +@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.""" + 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} + except Exception as e: + logger.exception("Error listing 3x-ui inbounds") + return JSONResponse({'error': str(e)}, status_code=500) + + @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/store.py b/db/store.py index 641d3b2..4e7c515 100644 --- a/db/store.py +++ b/db/store.py @@ -44,6 +44,17 @@ DEFAULT_SETTINGS = { 'xui_create_conns': False, 'xui_server_id': 0, 'xui_protocol': 'xray', + 'xui_inbound_id': 0, + }, + 'guest': { + 'enabled': False, + 'token': '', + 'password_hash': None, + 'user_id': '', + 'allow_create': False, + 'create_protocol': 'xui', + 'create_server_id': 0, + 'create_inbound_id': 0, }, } diff --git a/managers/xui_api.py b/managers/xui_api.py new file mode 100644 index 0000000..4edb864 --- /dev/null +++ b/managers/xui_api.py @@ -0,0 +1,350 @@ +"""3x-ui HTTP API client — create VLESS clients and fetch share links. + +Works with modern panels (/panel/api/clients/*) and falls back to legacy +/panel/api/inbounds/addClient when needed. +""" + +from __future__ import annotations + +import json +import logging +import secrets +import string +import uuid +from typing import Any, Optional +from urllib.parse import quote + +import httpx + +logger = logging.getLogger(__name__) + + +def _new_sub_id(length: int = 16) -> str: + alphabet = string.ascii_lowercase + string.digits + return ''.join(secrets.choice(alphabet) for _ in range(length)) + + +def _settings_creds(settings: dict) -> dict: + sync = (settings or {}).get('sync') or {} + return { + 'url': (sync.get('xui_url') or '').strip().rstrip('/'), + 'api_token': (sync.get('xui_api_token') or '').strip(), + 'username': (sync.get('xui_username') or '').strip(), + 'password': sync.get('xui_password') or '', + 'inbound_id': sync.get('xui_inbound_id'), + } + + +class XuiApiError(RuntimeError): + pass + + +class XuiApi: + def __init__(self, base_url: str, *, api_token: str = '', username: str = '', password: str = ''): + self.base_url = (base_url or '').rstrip('/') + if not self.base_url: + raise XuiApiError('3x-ui URL is not configured') + self.api_token = (api_token or '').strip() + self.username = (username or '').strip() + self.password = password or '' + self._client: Optional[httpx.AsyncClient] = None + + @classmethod + def from_panel_settings(cls, settings: dict) -> 'XuiApi': + c = _settings_creds(settings) + return cls(c['url'], api_token=c['api_token'], username=c['username'], password=c['password']) + + async def __aenter__(self) -> 'XuiApi': + headers = {'Accept': 'application/json'} + if self.api_token: + headers['Authorization'] = f'Bearer {self.api_token}' + self._client = httpx.AsyncClient( + base_url=self.base_url, + timeout=30.0, + follow_redirects=True, + headers=headers, + ) + if not self.api_token: + await self._login() + return self + + async def __aexit__(self, *args): + if self._client: + await self._client.aclose() + self._client = None + + async def _login(self): + if not self.username or not self.password: + raise XuiApiError('Provide 3x-ui API token or username/password') + resp = await self._client.post('/login', json={ + 'username': self.username, + 'password': self.password, + }) + if resp.status_code != 200: + raise XuiApiError(f'3x-ui login failed: HTTP {resp.status_code}') + try: + body = resp.json() + except Exception: + body = {} + if body.get('success') is False: + raise XuiApiError(f"3x-ui login failed: {body.get('msg', 'unknown error')}") + + async def _request(self, method: str, path: str, **kwargs) -> Any: + assert self._client is not None + resp = await self._client.request(method, path, **kwargs) + try: + payload = resp.json() + except Exception: + raise XuiApiError(f'3x-ui {method} {path}: HTTP {resp.status_code} non-JSON') + if resp.status_code >= 400: + raise XuiApiError( + f"3x-ui {method} {path}: HTTP {resp.status_code} {payload.get('msg') or payload}" + ) + if isinstance(payload, dict) and payload.get('success') is False: + raise XuiApiError(payload.get('msg') or f'3x-ui {method} {path} failed') + return payload + + async def list_inbounds(self) -> list: + for path, method in ( + ('/panel/api/inbounds/list', 'GET'), + ('/panel/api/inbounds/list', 'POST'), + ('/panel/inbound/list', 'POST'), + ): + try: + payload = await self._request(method, path) + except XuiApiError: + continue + obj = payload.get('obj') if isinstance(payload, dict) else None + if isinstance(obj, list): + return obj + return [] + + async def list_vless_inbounds(self) -> list: + result = [] + for inbound in await self.list_inbounds(): + if not isinstance(inbound, dict): + continue + proto = (inbound.get('protocol') or '').lower() + if proto != 'vless': + continue + result.append({ + 'id': inbound.get('id'), + 'remark': inbound.get('remark') or f"VLESS:{inbound.get('port')}", + 'port': inbound.get('port'), + 'protocol': proto, + 'enable': bool(inbound.get('enable', True)), + }) + return result + + async def add_vless_client( + self, + *, + email: str, + inbound_id: int, + comment: str = '', + enable: bool = True, + total_gb: int = 0, + expiry_time: int = 0, + limit_ip: int = 0, + flow: str = '', + ) -> dict: + email = (email or '').strip() + if not email: + raise XuiApiError('Client email is required') + if not inbound_id: + raise XuiApiError('VLESS inbound id is required') + + client_uuid = str(uuid.uuid4()) + sub_id = _new_sub_id() + client = { + 'id': client_uuid, + 'email': email, + 'enable': enable, + 'flow': flow or '', + 'limitIp': limit_ip, + 'totalGB': total_gb, + 'expiryTime': expiry_time, + 'tgId': 0, + 'subId': sub_id, + 'comment': comment or '', + } + + # Modern API + try: + await self._request( + 'POST', + '/panel/api/clients/add', + json={'client': client, 'inboundIds': [int(inbound_id)]}, + ) + except XuiApiError as modern_err: + logger.info('Modern clients/add failed (%s), trying legacy addClient', modern_err) + # Legacy: settings must be a JSON-encoded string on many builds + settings_obj = {'clients': [client]} + try: + await self._request( + 'POST', + '/panel/api/inbounds/addClient', + json={ + 'id': int(inbound_id), + 'settings': json.dumps(settings_obj), + }, + ) + except XuiApiError: + # Some builds accept nested object + await self._request( + 'POST', + '/panel/api/inbounds/addClient', + json={ + 'id': int(inbound_id), + 'settings': settings_obj, + }, + ) + + links = await self.get_client_links(email) + vless = next((u for u in links if isinstance(u, str) and u.startswith('vless://')), None) + if not vless and links: + vless = links[0] + return { + 'client_id': email, + 'uuid': client_uuid, + 'sub_id': sub_id, + 'email': email, + 'config': vless or '', + 'links': links, + } + + async def get_client_links(self, email: str) -> list: + email = (email or '').strip() + if not email: + return [] + path = f'/panel/api/clients/links/{quote(email, safe="")}' + try: + payload = await self._request('GET', path) + obj = payload.get('obj') if isinstance(payload, dict) else None + if isinstance(obj, list): + return [x for x in obj if isinstance(x, str) and x.strip()] + except XuiApiError as e: + logger.warning('get_client_links failed: %s', e) + return [] + + async def delete_client(self, email: str) -> None: + email = (email or '').strip() + if not email: + return + path = f'/panel/api/clients/del/{quote(email, safe="")}' + try: + await self._request('POST', path) + return + except XuiApiError as e: + logger.warning('clients/del failed (%s), trying legacy', e) + # Legacy: need inbound id — try remove by scanning + for inbound in await self.list_inbounds(): + if not isinstance(inbound, dict): + continue + settings = inbound.get('settings') + if isinstance(settings, str): + try: + settings = json.loads(settings) + except Exception: + settings = {} + clients = (settings or {}).get('clients') if isinstance(settings, dict) else None + if not isinstance(clients, list): + continue + match = next((c for c in clients if isinstance(c, dict) and c.get('email') == email), None) + if not match: + continue + cid = match.get('id') or email + inbound_id = inbound.get('id') + for path in ( + f'/panel/api/inbounds/{inbound_id}/delClient/{quote(str(cid), safe="")}', + f'/panel/api/inbounds/{inbound_id}/delClientByEmail/{quote(email, safe="")}', + ): + try: + await self._request('POST', path) + return + except XuiApiError: + continue + raise XuiApiError(f'Failed to delete 3x-ui client {email}') + + async def set_client_enabled(self, email: str, enable: bool) -> None: + email = (email or '').strip() + if not email: + return + # Prefer full client get + update + try: + payload = await self._request('GET', f'/panel/api/clients/get/{quote(email, safe="")}') + obj = payload.get('obj') if isinstance(payload, dict) else None + client = None + if isinstance(obj, dict): + client = obj.get('client') if isinstance(obj.get('client'), dict) else obj + if isinstance(client, dict): + client = dict(client) + client['enable'] = bool(enable) + client['email'] = email + await self._request( + 'POST', + f'/panel/api/clients/update/{quote(email, safe="")}', + json=client, + ) + return + except XuiApiError as e: + logger.warning('toggle via clients/update failed: %s', e) + raise XuiApiError(f'Failed to toggle 3x-ui client {email}') + + +async def xui_create_vless_config(settings: dict, *, name: str, inbound_id: Optional[int] = None) -> dict: + """Create a VLESS client on 3x-ui and return {client_id, config, links}.""" + creds = _settings_creds(settings) + 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: + if not inbound: + vless_inbounds = await api.list_vless_inbounds() + if not vless_inbounds: + raise XuiApiError('No VLESS inbound found on 3x-ui — create one in the panel first') + inbound = int(vless_inbounds[0]['id']) + + # Sanitize email: 3x-ui emails are unique free-form ids + base = ''.join(ch if ch.isalnum() or ch in '._-+@' else '_' for ch in (name or 'user').strip()) + base = base[:48] or f'user_{secrets.token_hex(4)}' + email = base + # Avoid collisions + for _ in range(5): + try: + return await api.add_vless_client(email=email, inbound_id=inbound, comment=name or email) + except XuiApiError as e: + if 'exist' in str(e).lower() or 'duplicate' in str(e).lower() or 'already' in str(e).lower(): + email = f'{base}_{secrets.token_hex(3)}' + continue + raise + return await api.add_vless_client(email=email, inbound_id=inbound, comment=name or email) + + +async def xui_get_config(settings: dict, email: str) -> str: + async with XuiApi.from_panel_settings(settings) as api: + links = await api.get_client_links(email) + vless = next((u for u in links if u.startswith('vless://')), None) + if vless: + return vless + if links: + return links[0] + 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: + 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: + 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: + return await api.list_vless_inbounds() diff --git a/templates/guest.html b/templates/guest.html new file mode 100644 index 0000000..542152c --- /dev/null +++ b/templates/guest.html @@ -0,0 +1,289 @@ +{% extends "base.html" %} + +{% block title_extra %} — {{ _('guest_title') }}{% endblock %} + +{% block content %} +{{ _('guest_subtitle') }}
+{{ _('guest_protected_desc') }}
+ + +{{ _('loading_share_conns') }}
+{{ _('no_active_conns') }}
+