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', lang)}

{_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_title') }}

+

{{ _('guest_subtitle') }}

+
+ + {% if need_password %} +
+
🔐
+

{{ _('guest_protected_desc') }}

+ +
+
+ +
+ +
+
+ {% else %} +
+ {% if allow_create %} +
+ +
+ {{ _('guest_get_config_hint') }} +
+
+ {% endif %} + +
+
+

{{ _('loading_share_conns') }}

+
+ + +
+ {% endif %} +
+ + + + + + +{% endblock %} diff --git a/templates/login.html b/templates/login.html index 268a14d..8de7e60 100644 --- a/templates/login.html +++ b/templates/login.html @@ -131,6 +131,15 @@ + + {% if guest_link %} +
+ + {{ _('guest_access_btn') }} + +
{{ _('guest_access_login_hint') }}
+
+ {% endif %} diff --git a/templates/settings.html b/templates/settings.html index cb6fe73..a3e21d4 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -45,6 +45,97 @@ + +
+

{{ _('guest_settings_title') }}

+
+
+ +
{{ _('guest_enable_hint') }}
+
+ +
+
+ +
+ + + +
+ +
+ +
+ + +
{{ _('guest_user_hint') }}
+
+ +
+ + + {% if settings.guest.password_hash %} + + {% endif %} +
+ +
+ +
{{ _('guest_allow_create_hint') }}
+
+ +
+
+ + +
+
+ + +
+
+
+
+
+

{{ _('telegram_bot_title') }}

@@ -394,6 +485,14 @@
+
+ + +
{{ _('xui_inbound_hint') }}
+
+
@@ -616,8 +715,39 @@ 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 }}; + + async function loadXuiInbounds() { + const select = document.getElementById('xuiInboundSelect'); + if (!select) return; + const prev = select.value || String(xuiDefaultInboundId || '0'); + select.innerHTML = ``; + try { + const res = await fetch('/api/settings/xui/inbounds'); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to load inbounds'); + (data.inbounds || []).forEach(ib => { + const opt = document.createElement('option'); + opt.value = ib.id; + opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`; + select.appendChild(opt); + }); + 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(); @@ -897,7 +1027,8 @@ xui_api_token: syncForm.xui_api_token.value.trim(), xui_create_conns: syncForm.xui_create_conns.checked, xui_server_id: parseInt(syncForm.xui_server_id.value || '0'), - xui_protocol: syncForm.xui_protocol.value + xui_protocol: syncForm.xui_protocol.value, + xui_inbound_id: parseInt(syncForm.xui_inbound_id.value || '0') }; const res = await fetch('/api/settings/xui_sync_now', { method: 'POST', @@ -978,7 +1109,8 @@ xui_sync_users: syncForm.xui_sync_users.checked, xui_create_conns: syncForm.xui_create_conns.checked, xui_server_id: parseInt(syncForm.xui_server_id.value || '0'), - xui_protocol: syncForm.xui_protocol.value + xui_protocol: syncForm.xui_protocol.value, + xui_inbound_id: parseInt(syncForm.xui_inbound_id.value || '0') }; @@ -1001,11 +1133,34 @@ panel_port: parseInt(document.getElementById('panel_port').value || '5000') }; + let createProtocol = 'xui'; + let createServerId = 0; + const guestProtoRaw = document.getElementById('guest_create_protocol').value; + if (guestProtoRaw === 'xui') { + createProtocol = 'xui'; + } else if (guestProtoRaw.includes('|')) { + const parts = guestProtoRaw.split('|'); + createProtocol = parts[0]; + createServerId = parseInt(parts[1] || '0'); + } + + const guest = { + enabled: document.getElementById('guest_enabled').checked, + token: document.getElementById('guest_token').value || '', + password: document.getElementById('guest_password').value || '', + clear_password: !!(document.getElementById('guest_clear_password') && document.getElementById('guest_clear_password').checked), + user_id: document.getElementById('guest_user_id').value || '', + 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') + }; + try { const res = await fetch('/api/settings/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ appearance, sync, captcha, telegram, ssl }) + body: JSON.stringify({ appearance, sync, captcha, telegram, ssl, guest }) }); if (!res.ok) throw new Error(_('error')); showToast(_('settings_saved'), 'success'); @@ -1018,6 +1173,58 @@ } } + function updateGuestCreateFields() { + const v = document.getElementById('guest_create_protocol')?.value; + const group = document.getElementById('guestInboundGroup'); + if (group) group.style.display = (v === 'xui') ? 'block' : 'none'; + } + + function copyGuestLink() { + const el = document.getElementById('guest_link_display'); + if (el && el.value) copyToClipboard(el.value); + } + + async function regenGuestToken() { + if (!confirm(_('guest_regen_confirm'))) return; + try { + const res = await fetch('/api/settings/guest/regenerate_token', { method: 'POST' }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || _('error')); + document.getElementById('guest_token').value = data.token; + const link = `${window.location.origin}/guest/${data.token}`; + document.getElementById('guest_link_display').value = link; + showToast(_('guest_token_updated'), 'success'); + } catch (err) { + showToast(`${_('error')}: ` + err.message, 'error'); + } + } + + async function loadGuestInbounds() { + const select = document.getElementById('guest_create_inbound_id'); + if (!select) return; + const preferred = {{ settings.guest.create_inbound_id | default(0) | int }}; + select.innerHTML = ``; + try { + const res = await fetch('/api/settings/xui/inbounds'); + const data = await res.json(); + if (!res.ok) return; + (data.inbounds || []).forEach(ib => { + const opt = document.createElement('option'); + opt.value = ib.id; + opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`; + if (String(ib.id) === String(preferred)) opt.selected = true; + select.appendChild(opt); + }); + } catch (e) { /* ignore */ } + } + + if (document.getElementById('guest_allow_create')?.checked) { + loadGuestInbounds(); + } + document.getElementById('guest_allow_create')?.addEventListener('change', (e) => { + if (e.target.checked) loadGuestInbounds(); + }); + async function toggleBot() { const btn = document.getElementById('toggleBotBtn'); const text = document.getElementById('toggleBotBtnText'); diff --git a/templates/users.html b/templates/users.html index 6eb2a5a..e330be6 100644 --- a/templates/users.html +++ b/templates/users.html @@ -291,6 +291,14 @@
{{ _('existing_conn_hint') }}
+ +
@@ -397,6 +405,8 @@ let searchTimeout = null; const serversData = {{ servers | tojson }}; + const xuiConfigured = {{ 'true' if xui_configured else 'false' }}; + const xuiDefaultInboundId = {{ xui_inbound_id | int }}; function populateTimeSelect(selectId) { const select = document.getElementById(selectId); @@ -587,16 +597,11 @@ const group = groupId ? document.getElementById(groupId) : null; select.innerHTML = ''; const vpnBases = new Set(['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard']); - - if (serverId === '') { - if (group) group.style.display = 'none'; - return; - } - - const server = serversData[serverId]; - const protocols = server.protocols || {}; let count = 0; + const server = (serverId !== '' && serversData[serverId]) ? serversData[serverId] : null; + const protocols = (server && server.protocols) || {}; + for (const [key, info] of Object.entries(protocols)) { if (!info.installed) continue; const base = key.split('__')[0]; @@ -608,6 +613,14 @@ count++; } + if (xuiConfigured && selectId === 'ucProtocol') { + const opt = document.createElement('option'); + opt.value = 'xui'; + opt.textContent = '3x-ui VLESS'; + select.appendChild(opt); + count++; + } + if (count > 0) { if (group) group.style.display = ''; } else { @@ -617,6 +630,39 @@ select.appendChild(opt); if (group) group.style.display = ''; } + if (selectId === 'ucProtocol') { + updateXuiInboundVisibility(); + } + } + + async function loadXuiInbounds() { + const select = document.getElementById('ucXuiInbound'); + if (!select) return; + select.innerHTML = ``; + try { + const data = await apiCall('/api/settings/xui/inbounds'); + (data.inbounds || []).forEach(ib => { + const opt = document.createElement('option'); + opt.value = ib.id; + opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`; + if (String(ib.id) === String(xuiDefaultInboundId)) opt.selected = true; + select.appendChild(opt); + }); + } catch (err) { + showToast(`${_('error')}: ${err.message}`, 'error'); + } + } + + function updateXuiInboundVisibility() { + const proto = document.getElementById('ucProtocol')?.value; + const group = document.getElementById('ucXuiInboundGroup'); + if (!group) return; + if (proto === 'xui') { + group.style.display = 'block'; + loadXuiInbounds(); + } else { + group.style.display = 'none'; + } } // Show/hide connection fields @@ -649,6 +695,7 @@ fetchExistingClients(); } updateTelemtOptions('ucProtocol', 'ucTelemtOptions'); + updateXuiInboundVisibility(); }); async function addUser(e) { @@ -806,7 +853,7 @@ try { const body = { - server_id: parseInt(document.getElementById('ucServer').value), + server_id: parseInt(document.getElementById('ucServer').value) || 0, protocol: document.getElementById('ucProtocol').value, name: document.getElementById('ucName').value || 'VPN Connection', }; @@ -815,6 +862,9 @@ const clientId = document.getElementById('ucExistingClient').value; if (!clientId) throw new Error(_('select_connection')); body.client_id = clientId; + } else if (body.protocol === 'xui') { + const inbound = document.getElementById('ucXuiInbound').value; + if (inbound) body.xui_inbound_id = parseInt(inbound); } else if (body.protocol === 'telemt') { body.telemt_quota = document.getElementById('ucTelemtQuota').value || null; body.telemt_max_ips = parseInt(document.getElementById('ucTelemtMaxIps').value) || null; @@ -870,7 +920,7 @@
${c.name || 'Connection'}
🖥 ${c.server_name || ''} - ${c.protocol === 'awg' ? 'AmneziaWG' : (c.protocol === 'awg2' ? 'AmneziaWG 2.0' : (c.protocol === 'awg_legacy' ? 'AWG Legacy' : (c.protocol === 'xray' ? 'Xray' : c.protocol.toUpperCase())))} + ${c.protocol === 'awg' ? 'AmneziaWG' : (c.protocol === 'awg2' ? 'AmneziaWG 2.0' : (c.protocol === 'awg_legacy' ? 'AWG Legacy' : (c.protocol === 'xray' ? 'Xray' : (c.protocol === 'xui' ? '3x-ui VLESS' : c.protocol.toUpperCase()))))}
diff --git a/translations/en.json b/translations/en.json index 05eae47..963216e 100644 --- a/translations/en.json +++ b/translations/en.json @@ -170,6 +170,32 @@ "enable_public_access": "Enable public access", "share_password_label": "Password (optional)", "share_password_hint": "If set, user must enter it", + "guest_settings_title": "Guest access", + "guest_enable": "Enable guest access (no registration)", + "guest_enable_hint": "Public link on the login page. Guests see connections of the selected user and can optionally create a new config.", + "guest_link_label": "Guest link", + "guest_link_placeholder": "Save settings to generate a link", + "guest_regen_token": "Regenerate link", + "guest_regen_confirm": "Regenerate guest link? The old link will stop working.", + "guest_token_updated": "Guest link updated", + "guest_user_label": "Guest connections user", + "guest_user_none": "— Select user —", + "guest_user_hint": "Create a regular user (e.g. guest), assign VPN connections — guests will see them without logging in.", + "guest_password_label": "Guest password (optional)", + "guest_password_keep": "Leave blank to keep current password", + "guest_clear_password": "Remove password protection", + "guest_allow_create": "Allow guests to create a new config", + "guest_allow_create_hint": "Creates a config for the guest user (3x-ui VLESS or selected server protocol).", + "guest_access_btn": "Continue as guest", + "guest_access_login_hint": "No account required", + "guest_title": "Guest access", + "guest_subtitle": "Get a VPN config without registration", + "guest_protected_desc": "This guest page is password protected.", + "guest_get_config": "Get config", + "guest_get_config_hint": "A new VPN config will be created for you", + "guest_not_found": "404 Not Found", + "guest_not_found_desc": "Guest access is disabled or the link is invalid.", + "wrong_guest_password": "Wrong password", "public_link_label": "Public link", "disabled": "Disabled", "out_of": "Of", @@ -221,6 +247,9 @@ "xui_username_label": "3x-ui Username", "xui_password_label": "3x-ui Password", "xui_sync_hint": "Clients from 3x-ui inbounds will be created, disabled, and deleted as panel users (matched by email)", + "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.", "sync_now_btn": "🔄 Sync now", "delete_sync_btn": "🗑 Delete synchronization", "auto_create_conns": "Create connections automatically", diff --git a/translations/fa.json b/translations/fa.json index 5058171..1917fc1 100644 --- a/translations/fa.json +++ b/translations/fa.json @@ -168,6 +168,32 @@ "enable_public_access": "فعال‌سازی دسترسی عمومی", "share_password_label": "رمز عبور (اختیاری)", "share_password_hint": "در صورت تنظیم، کاربر باید آن را وارد کند", + "guest_settings_title": "دسترسی مهمان", + "guest_enable": "فعال‌سازی دسترسی مهمان (بدون ثبت‌نام)", + "guest_enable_hint": "لینک عمومی در صفحه ورود. مهمانان اتصال‌های کاربر انتخاب‌شده را می‌بینند.", + "guest_link_label": "لینک مهمان", + "guest_link_placeholder": "برای ساخت لینک تنظیمات را ذخیره کنید", + "guest_regen_token": "بازسازی لینک", + "guest_regen_confirm": "لینک مهمان بازسازی شود؟ لینک قبلی از کار می‌افتد.", + "guest_token_updated": "لینک مهمان به‌روز شد", + "guest_user_label": "کاربر اتصال‌های مهمان", + "guest_user_none": "— انتخاب کاربر —", + "guest_user_hint": "یک کاربر عادی (مثل guest) بسازید و VPN اختصاص دهید.", + "guest_password_label": "رمز مهمان (اختیاری)", + "guest_password_keep": "برای حفظ رمز فعلی خالی بگذارید", + "guest_clear_password": "حذف محافظت با رمز", + "guest_allow_create": "اجازه ساخت کانفیگ جدید به مهمان", + "guest_allow_create_hint": "برای کاربر مهمان کانفیگ می‌سازد (3x-ui VLESS یا پروتکل سرور).", + "guest_access_btn": "ادامه به‌عنوان مهمان", + "guest_access_login_hint": "بدون حساب کاربری", + "guest_title": "دسترسی مهمان", + "guest_subtitle": "دریافت کانفیگ VPN بدون ثبت‌نام", + "guest_protected_desc": "این صفحه با رمز عبور محافظت شده است.", + "guest_get_config": "دریافت کانفیگ", + "guest_get_config_hint": "یک کانفیگ VPN جدید برای شما ساخته می‌شود", + "guest_not_found": "404 یافت نشد", + "guest_not_found_desc": "دسترسی مهمان غیرفعال است یا لینک نامعتبر است.", + "wrong_guest_password": "رمز عبور اشتباه است", "public_link_label": "لینک عمومی", "disabled": "غیرفعال", "out_of": "از", @@ -219,6 +245,9 @@ "xui_username_label": "نام کاربری 3x-ui", "xui_password_label": "رمز عبور 3x-ui", "xui_sync_hint": "کلاینت‌های 3x-ui بر اساس email به کاربران پنل همگام می‌شوند", + "xui_inbound_label": "ورودی VLESS", + "xui_inbound_auto": "خودکار (اولین ورودی VLESS)", + "xui_inbound_hint": "برای ساخت کانفیگ 3x-ui VLESS استفاده می‌شود. ابتدا یک ورودی VLESS در 3x-ui بسازید.", "sync_now_btn": "🔄 همگام‌سازی اکنون", "delete_sync_btn": "🗑 حذف همگام‌سازی", "auto_create_conns": "ایجاد خودکار اتصال‌ها", diff --git a/translations/fr.json b/translations/fr.json index 6f4afec..1ae0c0b 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -168,6 +168,32 @@ "enable_public_access": "Activer l'accès public", "share_password_label": "Mot de passe (optionnel)", "share_password_hint": "Si défini, requis pour l'accès", + "guest_settings_title": "Accès invité", + "guest_enable": "Activer l'accès invité (sans inscription)", + "guest_enable_hint": "Lien public sur la page de connexion. Les invités voient les connexions de l'utilisateur choisi.", + "guest_link_label": "Lien invité", + "guest_link_placeholder": "Enregistrez pour générer le lien", + "guest_regen_token": "Régénérer le lien", + "guest_regen_confirm": "Régénérer le lien invité ? L'ancien ne fonctionnera plus.", + "guest_token_updated": "Lien invité mis à jour", + "guest_user_label": "Utilisateur des connexions invitées", + "guest_user_none": "— Choisir —", + "guest_user_hint": "Créez un utilisateur (ex. guest) et assignez des VPN.", + "guest_password_label": "Mot de passe invité (optionnel)", + "guest_password_keep": "Laisser vide pour conserver", + "guest_clear_password": "Retirer le mot de passe", + "guest_allow_create": "Autoriser la création d'une config", + "guest_allow_create_hint": "Crée une config pour l'utilisateur invité (3x-ui VLESS ou protocole serveur).", + "guest_access_btn": "Continuer en invité", + "guest_access_login_hint": "Sans compte", + "guest_title": "Accès invité", + "guest_subtitle": "Obtenir une config VPN sans inscription", + "guest_protected_desc": "Page protégée par mot de passe.", + "guest_get_config": "Obtenir la config", + "guest_get_config_hint": "Une nouvelle config VPN sera créée", + "guest_not_found": "404 Introuvable", + "guest_not_found_desc": "Accès invité désactivé ou lien invalide.", + "wrong_guest_password": "Mauvais mot de passe", "public_link_label": "Lien public", "disabled": "Désactivé", "out_of": "sur", @@ -219,6 +245,9 @@ "xui_username_label": "Identifiant 3x-ui", "xui_password_label": "Mot de passe 3x-ui", "xui_sync_hint": "Les clients 3x-ui deviennent des utilisateurs du panneau (liés par email)", + "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.", "sync_now_btn": "🔄 Sync maintenant", "delete_sync_btn": "🗑 Supprimer synchro", "auto_create_conns": "Créer connexions auto", diff --git a/translations/ru.json b/translations/ru.json index 20322a9..c84b98f 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -170,6 +170,32 @@ "enable_public_access": "Включить публичный доступ", "share_password_label": "Пароль (опционально)", "share_password_hint": "Если установлен, пользователь должен будет его ввести", + "guest_settings_title": "Гостевой доступ", + "guest_enable": "Включить гостевой доступ (без регистрации)", + "guest_enable_hint": "Публичная ссылка на странице входа. Гости видят подключения выбранного пользователя и могут создать новый конфиг.", + "guest_link_label": "Гостевая ссылка", + "guest_link_placeholder": "Сохраните настройки, чтобы создать ссылку", + "guest_regen_token": "Обновить ссылку", + "guest_regen_confirm": "Обновить гостевую ссылку? Старая перестанет работать.", + "guest_token_updated": "Гостевая ссылка обновлена", + "guest_user_label": "Пользователь для гостевых подключений", + "guest_user_none": "— Выберите пользователя —", + "guest_user_hint": "Создайте обычного пользователя (например guest), назначьте VPN — гости увидят их без входа.", + "guest_password_label": "Пароль гостя (опционально)", + "guest_password_keep": "Оставьте пустым, чтобы сохранить текущий", + "guest_clear_password": "Убрать защиту паролем", + "guest_allow_create": "Разрешить гостям создавать новый конфиг", + "guest_allow_create_hint": "Создаёт конфиг у гостевого пользователя (3x-ui VLESS или протокол выбранного сервера).", + "guest_access_btn": "Войти как гость", + "guest_access_login_hint": "Регистрация не нужна", + "guest_title": "Гостевой доступ", + "guest_subtitle": "Получите VPN-конфиг без регистрации", + "guest_protected_desc": "Эта страница защищена паролем.", + "guest_get_config": "Получить конфиг", + "guest_get_config_hint": "Для вас будет создан новый VPN-конфиг", + "guest_not_found": "404 Не найдено", + "guest_not_found_desc": "Гостевой доступ отключён или ссылка неверна.", + "wrong_guest_password": "Неверный пароль", "public_link_label": "Публичная ссылка", "disabled": "Отключён", "out_of": "Из", @@ -221,6 +247,9 @@ "xui_username_label": "Логин 3x-ui", "xui_password_label": "Пароль 3x-ui", "xui_sync_hint": "Клиенты из inbounds 3x-ui будут создаваться, отключаться и удаляться как пользователи панели (связь по email)", + "xui_inbound_label": "VLESS inbound", + "xui_inbound_auto": "Авто (первый VLESS inbound)", + "xui_inbound_hint": "Используется при создании конфигов 3x-ui VLESS. Сначала создайте VLESS inbound в 3x-ui.", "sync_now_btn": "🔄 Синхронизировать сейчас", "delete_sync_btn": "🗑 Удалить синхронизацию", "auto_create_conns": "Создавать подключения автоматически", diff --git a/translations/zh.json b/translations/zh.json index d0a1a35..c0204d7 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -168,6 +168,32 @@ "enable_public_access": "启用公开访问", "share_password_label": "密码 (可选)", "share_password_hint": "如设置,访问者需输入密码", + "guest_settings_title": "访客访问", + "guest_enable": "启用访客访问(无需注册)", + "guest_enable_hint": "登录页显示公开链接。访客可查看所选用户的连接,并可选择新建配置。", + "guest_link_label": "访客链接", + "guest_link_placeholder": "保存设置以生成链接", + "guest_regen_token": "重新生成链接", + "guest_regen_confirm": "重新生成访客链接?旧链接将失效。", + "guest_token_updated": "访客链接已更新", + "guest_user_label": "访客连接所属用户", + "guest_user_none": "— 选择用户 —", + "guest_user_hint": "创建普通用户(如 guest)并分配 VPN 连接。", + "guest_password_label": "访客密码(可选)", + "guest_password_keep": "留空以保留当前密码", + "guest_clear_password": "取消密码保护", + "guest_allow_create": "允许访客创建新配置", + "guest_allow_create_hint": "为访客用户创建配置(3x-ui VLESS 或所选服务器协议)。", + "guest_access_btn": "以访客继续", + "guest_access_login_hint": "无需账号", + "guest_title": "访客访问", + "guest_subtitle": "无需注册即可获取 VPN 配置", + "guest_protected_desc": "此页面受密码保护。", + "guest_get_config": "获取配置", + "guest_get_config_hint": "将为您创建新的 VPN 配置", + "guest_not_found": "404 未找到", + "guest_not_found_desc": "访客访问已禁用或链接无效。", + "wrong_guest_password": "密码错误", "public_link_label": "公开链接", "disabled": "已禁用", "out_of": "/", @@ -219,6 +245,9 @@ "xui_username_label": "3x-ui 用户名", "xui_password_label": "3x-ui 密码", "xui_sync_hint": "3x-ui 客户端将按 email 同步为面板用户", + "xui_inbound_label": "VLESS 入站", + "xui_inbound_auto": "自动(第一个 VLESS 入站)", + "xui_inbound_hint": "创建 3x-ui VLESS 配置时使用。请先在 3x-ui 中创建 VLESS 入站。", "sync_now_btn": "🔄 立即同步", "delete_sync_btn": "🗑 删除同步数据", "auto_create_conns": "自动创建连接",