Template
Add 3x-ui VLESS configs and guest access without registration.
Guests can open a public link to view or create VPN configs; panel creates VLESS clients via the 3x-ui API. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -964,7 +964,7 @@ def _manager_call(manager, method, protocol, *args, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
# Protocols that own VPN clients (can list/link connections)
|
# 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):
|
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)
|
user = next((u for u in data['users'] if u['id'] == user_id), None)
|
||||||
if not user:
|
if not user:
|
||||||
return False
|
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]
|
user_conns = [c for c in data.get('user_connections', []) if c['user_id'] == user_id]
|
||||||
for uc in user_conns:
|
for uc in user_conns:
|
||||||
try:
|
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']
|
sid = uc['server_id']
|
||||||
if sid < len(data['servers']):
|
if sid < len(data['servers']):
|
||||||
server = data['servers'][sid]
|
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]
|
user_conns = [c for c in data.get('user_connections', []) if c['user_id'] == user_id]
|
||||||
for uc in user_conns:
|
for uc in user_conns:
|
||||||
try:
|
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']
|
sid = uc['server_id']
|
||||||
if sid >= len(data['servers']):
|
if sid >= len(data['servers']):
|
||||||
continue
|
continue
|
||||||
@@ -1699,6 +1707,7 @@ class AddConnectionRequest(BaseModel):
|
|||||||
telemt_secret: Optional[str] = None
|
telemt_secret: Optional[str] = None
|
||||||
telemt_ad_tag: Optional[str] = None
|
telemt_ad_tag: Optional[str] = None
|
||||||
telemt_max_conns: Optional[int] = None
|
telemt_max_conns: Optional[int] = None
|
||||||
|
xui_inbound_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class EditConnectionRequest(BaseModel):
|
class EditConnectionRequest(BaseModel):
|
||||||
@@ -1783,6 +1792,7 @@ class SyncSettings(BaseModel):
|
|||||||
xui_create_conns: bool = False
|
xui_create_conns: bool = False
|
||||||
xui_server_id: int = 0
|
xui_server_id: int = 0
|
||||||
xui_protocol: str = 'xray'
|
xui_protocol: str = 'xray'
|
||||||
|
xui_inbound_id: int = 0
|
||||||
|
|
||||||
class CaptchaSettings(BaseModel):
|
class CaptchaSettings(BaseModel):
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
@@ -1802,6 +1812,16 @@ class TelegramSettings(BaseModel):
|
|||||||
enabled: bool = False
|
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):
|
class UpdateUserRequest(BaseModel):
|
||||||
@@ -1821,6 +1841,7 @@ class SaveSettingsRequest(BaseModel):
|
|||||||
captcha: CaptchaSettings
|
captcha: CaptchaSettings
|
||||||
telegram: TelegramSettings
|
telegram: TelegramSettings
|
||||||
ssl: SSLSettings
|
ssl: SSLSettings
|
||||||
|
guest: GuestSettings = GuestSettings()
|
||||||
|
|
||||||
|
|
||||||
class ToggleUserRequest(BaseModel):
|
class ToggleUserRequest(BaseModel):
|
||||||
@@ -1828,7 +1849,7 @@ class ToggleUserRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class AddUserConnectionRequest(BaseModel):
|
class AddUserConnectionRequest(BaseModel):
|
||||||
server_id: int
|
server_id: int = 0
|
||||||
protocol: str = 'awg'
|
protocol: str = 'awg'
|
||||||
name: str = 'VPN Connection'
|
name: str = 'VPN Connection'
|
||||||
client_id: Optional[str] = None
|
client_id: Optional[str] = None
|
||||||
@@ -1838,6 +1859,7 @@ class AddUserConnectionRequest(BaseModel):
|
|||||||
telemt_secret: Optional[str] = None
|
telemt_secret: Optional[str] = None
|
||||||
telemt_ad_tag: Optional[str] = None
|
telemt_ad_tag: Optional[str] = None
|
||||||
telemt_max_conns: Optional[int] = None
|
telemt_max_conns: Optional[int] = None
|
||||||
|
xui_inbound_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class CreateApiTokenRequest(BaseModel):
|
class CreateApiTokenRequest(BaseModel):
|
||||||
@@ -1853,6 +1875,10 @@ class ShareAuthRequest(BaseModel):
|
|||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class GuestCreateRequest(BaseModel):
|
||||||
|
name: str = 'Guest VPN'
|
||||||
|
|
||||||
|
|
||||||
class TunnelStartRequest(BaseModel):
|
class TunnelStartRequest(BaseModel):
|
||||||
authtoken: Optional[str] = None
|
authtoken: Optional[str] = None
|
||||||
|
|
||||||
@@ -2107,7 +2133,12 @@ async def periodic_background_tasks():
|
|||||||
async def login_page(request: Request):
|
async def login_page(request: Request):
|
||||||
if get_current_user(request):
|
if get_current_user(request):
|
||||||
return RedirectResponse(url='/', status_code=302)
|
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"])
|
@app.get("/set_lang/{lang}", tags=["System Templates"])
|
||||||
@@ -2164,7 +2195,15 @@ async def users_page(request: Request):
|
|||||||
for u in users_list:
|
for u in users_list:
|
||||||
u['connections_count'] = sum(1 for c in conns if c['user_id'] == u['id'])
|
u['connections_count'] = sum(1 for c in conns if c['user_id'] == u['id'])
|
||||||
servers = data['servers']
|
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"])
|
@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']]
|
conns = [c for c in data.get('user_connections', []) if c['user_id'] == user['id']]
|
||||||
# Enrich with server names
|
# Enrich with server names
|
||||||
for c in conns:
|
for c in conns:
|
||||||
|
if protocol_base(c.get('protocol', '')) == 'xui':
|
||||||
|
c['server_name'] = '3x-ui'
|
||||||
|
continue
|
||||||
sid = c.get('server_id', 0)
|
sid = c.get('server_id', 0)
|
||||||
if sid < len(data['servers']):
|
if sid < len(data['servers']):
|
||||||
c['server_name'] = data['servers'][sid].get('name', data['servers'][sid].get('host', ''))
|
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()
|
data = load_data()
|
||||||
if server_id >= len(data['servers']):
|
if server_id >= len(data['servers']):
|
||||||
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
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]
|
server = data['servers'][server_id]
|
||||||
proto_info = server.get('protocols', {}).get(req.protocol, {})
|
proto_info = server.get('protocols', {}).get(req.protocol, {})
|
||||||
port = proto_info.get('port', '55424')
|
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)
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||||
try:
|
try:
|
||||||
data = load_data()
|
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:
|
if not req.client_id:
|
||||||
return JSONResponse({'error': 'Client ID is required'}, status_code=400)
|
return JSONResponse({'error': 'Client ID is required'}, status_code=400)
|
||||||
ssh = get_ssh(server)
|
|
||||||
ssh.connect()
|
if protocol_base(req.protocol) == 'xui':
|
||||||
manager = get_protocol_manager(ssh, req.protocol)
|
from managers.xui_api import xui_delete_client
|
||||||
_manager_call(manager, 'remove_client', req.protocol, req.client_id)
|
await xui_delete_client(data.get('settings', {}), req.client_id)
|
||||||
ssh.disconnect()
|
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
|
# Remove from user_connections
|
||||||
data['user_connections'] = [
|
data['user_connections'] = [
|
||||||
c for c in data.get('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)
|
save_data(data)
|
||||||
return {'status': 'success'}
|
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)
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||||
try:
|
try:
|
||||||
data = load_data()
|
data = load_data()
|
||||||
if server_id >= len(data['servers']):
|
if not req.client_id:
|
||||||
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
return JSONResponse({'error': 'Client ID is required'}, status_code=400)
|
||||||
# Users can only view their own connections
|
# Users can only view their own connections
|
||||||
if user['role'] == 'user':
|
if user['role'] == 'user':
|
||||||
owned = any(
|
owned = any(
|
||||||
@@ -3357,9 +3430,16 @@ async def api_get_connection_config(request: Request, server_id: int, req: Conne
|
|||||||
)
|
)
|
||||||
if not owned:
|
if not owned:
|
||||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
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]
|
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, {})
|
proto_info = server.get('protocols', {}).get(req.protocol, {})
|
||||||
port = proto_info.get('port', '55424')
|
port = proto_info.get('port', '55424')
|
||||||
ssh = get_ssh(server)
|
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)
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||||
try:
|
try:
|
||||||
data = load_data()
|
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']):
|
if server_id >= len(data['servers']):
|
||||||
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
||||||
server = data['servers'][server_id]
|
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 = get_ssh(server)
|
||||||
ssh.connect()
|
ssh.connect()
|
||||||
manager = get_protocol_manager(ssh, req.protocol)
|
manager = get_protocol_manager(ssh, req.protocol)
|
||||||
_manager_call(manager, 'toggle_client', req.protocol, req.client_id, req.enable)
|
_manager_call(manager, 'toggle_client', req.protocol, req.client_id, req.enable)
|
||||||
ssh.disconnect()
|
ssh.disconnect()
|
||||||
status = 'enabled' if req.enable else 'disabled'
|
return {'status': 'success'}
|
||||||
return {'status': 'success', 'enabled': req.enable, 'message': f'Connection {status}'}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error toggling connection")
|
logger.exception("Error toggling connection")
|
||||||
return JSONResponse({'error': str(e)}, status_code=500)
|
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)
|
user = next((u for u in data['users'] if u['id'] == user_id), None)
|
||||||
if not user:
|
if not user:
|
||||||
return JSONResponse({'error': 'User not found'}, status_code=404)
|
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:
|
if protocol_base(req.protocol) not in CLIENT_VPN_BASES:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{'error': f'Protocol "{req.protocol}" does not support user connections'},
|
{'error': f'Protocol "{req.protocol}" does not support user connections'},
|
||||||
status_code=400,
|
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]
|
server = data['servers'][req.server_id]
|
||||||
proto_info = server.get('protocols', {}).get(req.protocol, {})
|
proto_info = server.get('protocols', {}).get(req.protocol, {})
|
||||||
port = proto_info.get('port', '55424')
|
port = proto_info.get('port', '55424')
|
||||||
@@ -3710,6 +3829,9 @@ async def api_get_user_connections(request: Request, user_id: str):
|
|||||||
data = load_data()
|
data = load_data()
|
||||||
conns = [c for c in data.get('user_connections', []) if c['user_id'] == user_id]
|
conns = [c for c in data.get('user_connections', []) if c['user_id'] == user_id]
|
||||||
for c in conns:
|
for c in conns:
|
||||||
|
if protocol_base(c.get('protocol', '')) == 'xui':
|
||||||
|
c['server_name'] = '3x-ui'
|
||||||
|
continue
|
||||||
sid = c.get('server_id', 0)
|
sid = c.get('server_id', 0)
|
||||||
if sid < len(data['servers']):
|
if sid < len(data['servers']):
|
||||||
c['server_name'] = data['servers'][sid].get('name', '')
|
c['server_name'] = data['servers'][sid].get('name', '')
|
||||||
@@ -3726,6 +3848,9 @@ async def api_my_connections(request: Request):
|
|||||||
data = load_data()
|
data = load_data()
|
||||||
conns = [c for c in data.get('user_connections', []) if c['user_id'] == user['id']]
|
conns = [c for c in data.get('user_connections', []) if c['user_id'] == user['id']]
|
||||||
for c in conns:
|
for c in conns:
|
||||||
|
if protocol_base(c.get('protocol', '')) == 'xui':
|
||||||
|
c['server_name'] = '3x-ui'
|
||||||
|
continue
|
||||||
sid = c.get('server_id', 0)
|
sid = c.get('server_id', 0)
|
||||||
if sid < len(data['servers']):
|
if sid < len(data['servers']):
|
||||||
c['server_name'] = data['servers'][sid].get('name', '')
|
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)
|
return JSONResponse({'error': 'Not found'}, status_code=404)
|
||||||
|
|
||||||
try:
|
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']
|
sid = conn['server_id']
|
||||||
server = data['servers'][sid]
|
server = data['servers'][sid]
|
||||||
proto_info = server.get('protocols', {}).get(conn['protocol'], {})
|
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)
|
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"<h1>{_t('guest_not_found', lang)}</h1><p>{_t('guest_not_found_desc', lang)}</p>",
|
||||||
|
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"])
|
@app.post('/api/my/connections/{connection_id}/config', tags=["Self-service"])
|
||||||
async def api_my_connection_config(request: Request, connection_id: str):
|
async def api_my_connection_config(request: Request, connection_id: str):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
@@ -3855,6 +4220,13 @@ async def api_my_connection_config(request: Request, connection_id: str):
|
|||||||
)
|
)
|
||||||
if not conn:
|
if not conn:
|
||||||
return JSONResponse({'error': 'Connection not found'}, status_code=404)
|
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']
|
sid = conn['server_id']
|
||||||
if sid >= len(data['servers']):
|
if sid >= len(data['servers']):
|
||||||
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
||||||
@@ -3880,7 +4252,14 @@ async def settings_page(request: Request):
|
|||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse('/login')
|
return RedirectResponse('/login')
|
||||||
data = load_data()
|
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"])
|
@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']['captcha'] = payload.captcha.dict()
|
||||||
data['settings']['telegram'] = payload.telegram.dict()
|
data['settings']['telegram'] = payload.telegram.dict()
|
||||||
data['settings']['ssl'] = payload.ssl.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)
|
save_data(data)
|
||||||
logger.info("Settings saved (including captcha and telegram)")
|
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)...")
|
logger.info("Stopping Telegram bot (settings save)...")
|
||||||
asyncio.create_task(tg_bot.stop_bot())
|
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"])
|
@app.post('/api/settings/telegram/toggle', tags=["Settings"])
|
||||||
@@ -4086,6 +4482,7 @@ async def api_xui_sync_now(request: Request):
|
|||||||
for key in (
|
for key in (
|
||||||
'xui_url', 'xui_username', 'xui_password', 'xui_api_token',
|
'xui_url', 'xui_username', 'xui_password', 'xui_api_token',
|
||||||
'xui_create_conns', 'xui_server_id', 'xui_protocol', 'xui_sync_users', 'xui_sync',
|
'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:
|
if key in body and body[key] is not None:
|
||||||
sync_cfg[key] = body[key]
|
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)}
|
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"])
|
@app.get('/api/servers/{server_id}/{protocol}/clients', tags=["Connections"])
|
||||||
async def api_get_server_clients(request: Request, server_id: int, protocol: str):
|
async def api_get_server_clients(request: Request, server_id: int, protocol: str):
|
||||||
if not _check_admin(request):
|
if not _check_admin(request):
|
||||||
|
|||||||
+11
@@ -44,6 +44,17 @@ DEFAULT_SETTINGS = {
|
|||||||
'xui_create_conns': False,
|
'xui_create_conns': False,
|
||||||
'xui_server_id': 0,
|
'xui_server_id': 0,
|
||||||
'xui_protocol': 'xray',
|
'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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title_extra %} — {{ _('guest_title') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card" style="max-width: 600px; margin: 2rem auto;">
|
||||||
|
<div class="card-header"
|
||||||
|
style="justify-content: center; text-align: center; flex-direction: column; gap: var(--space-xs);">
|
||||||
|
<h2 class="card-title">{{ _('guest_title') }}</h2>
|
||||||
|
<p style="color: var(--text-muted); font-size: 0.9rem;">{{ _('guest_subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if need_password %}
|
||||||
|
<div style="padding: var(--space-lg); text-align: center;">
|
||||||
|
<div class="logo-icon" style="font-size: 3rem; margin-bottom: var(--space-md);">🔐</div>
|
||||||
|
<p style="margin-bottom: var(--space-md);">{{ _('guest_protected_desc') }}</p>
|
||||||
|
|
||||||
|
<form id="authForm" onsubmit="authGuest(event)"
|
||||||
|
style="display: flex; flex-direction: column; gap: var(--space-md); max-width: 300px; margin: 0 auto;">
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="password" id="guestPassword" class="form-input" placeholder="{{ _('password') }}" required
|
||||||
|
autofocus>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" id="authBtn">
|
||||||
|
<span id="authBtnText">{{ _('login') }}</span>
|
||||||
|
<div class="spinner hidden" id="authSpinner"></div>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div id="connectionsList" style="padding: var(--space-md);">
|
||||||
|
{% if allow_create %}
|
||||||
|
<div style="margin-bottom: var(--space-md);">
|
||||||
|
<button class="btn btn-primary" style="width:100%;" onclick="createGuestConfig()" id="createBtn">
|
||||||
|
<span id="createBtnText">{{ _('guest_get_config') }}</span>
|
||||||
|
<div class="spinner hidden" id="createSpinner" style="width:14px;height:14px;"></div>
|
||||||
|
</button>
|
||||||
|
<div class="form-hint" style="text-align:center; margin-top: var(--space-xs);">
|
||||||
|
{{ _('guest_get_config_hint') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="text-align: center; padding: 2rem;" id="loadingState">
|
||||||
|
<div class="spinner" style="width: 40px; height: 40px; margin: 0 auto;"></div>
|
||||||
|
<p style="margin-top: 1rem; color: var(--text-muted);">{{ _('loading_share_conns') }}</p>
|
||||||
|
</div>
|
||||||
|
<div id="connectionsGrid" class="hidden" style="display: grid; gap: var(--space-md);">
|
||||||
|
</div>
|
||||||
|
<div id="emptyState" class="hidden" style="text-align: center; padding: 2rem;">
|
||||||
|
<p style="color: var(--text-muted);">{{ _('no_active_conns') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" id="configModal">
|
||||||
|
<div class="modal" style="max-width: 600px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 class="modal-title" id="configModalTitle">{{ _('config') }}</h2>
|
||||||
|
<button class="modal-close" onclick="closeModal('configModal')">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-tabs">
|
||||||
|
<button class="config-tab active" onclick="switchConfigTab('conf')">{{ _('config_tab') }}</button>
|
||||||
|
<button class="config-tab" onclick="switchConfigTab('vpn')">{{ _('vpn_key_tab') }}</button>
|
||||||
|
<button class="config-tab" onclick="switchConfigTab('qr')">{{ _('qr_code_tab') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-panel active" id="panel-conf">
|
||||||
|
<div class="config-display">
|
||||||
|
<textarea class="config-text" id="configText" readonly rows="12"
|
||||||
|
style="width:100%; border:none; background:transparent; color:inherit; font-family:monospace; resize:none; outline:none;"></textarea>
|
||||||
|
<div class="config-actions">
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="copyConfig()" style="flex:1">{{ _('copy_config')
|
||||||
|
}}</button>
|
||||||
|
<a id="downloadBtn" class="btn btn-primary btn-sm"
|
||||||
|
style="flex:1; text-decoration:none; display:flex; align-items:center; justify-content:center;">
|
||||||
|
{{ _('download_conf') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-panel" id="panel-vpn">
|
||||||
|
<div class="vpn-link-box" id="vpnLinkText" style="min-height: 100px;"></div>
|
||||||
|
<div class="config-actions" style="margin-top:var(--space-sm);">
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="copyVpnLink()" style="flex:1">{{ _('copy_key')
|
||||||
|
}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-panel" id="panel-qr">
|
||||||
|
<div class="qr-container">
|
||||||
|
<div id="qrcode"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const TOKEN = "{{ token }}";
|
||||||
|
const ALLOW_CREATE = {{ 'true' if allow_create else 'false' }};
|
||||||
|
|
||||||
|
async function authGuest(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const password = document.getElementById('guestPassword').value;
|
||||||
|
const btn = document.getElementById('authBtn');
|
||||||
|
const text = document.getElementById('authBtnText');
|
||||||
|
const spinner = document.getElementById('authSpinner');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
text.classList.add('hidden');
|
||||||
|
spinner.classList.remove('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/guest/${TOKEN}/auth`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === 'success') {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert(data.error || _('login_error'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`${_('error')}: ` + err.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
text.classList.remove('hidden');
|
||||||
|
spinner.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function protoLabel(p) {
|
||||||
|
if (p === 'awg') return 'AmneziaWG';
|
||||||
|
if (p === 'awg2') return 'AmneziaWG 2.0';
|
||||||
|
if (p === 'awg_legacy') return 'AWG Legacy';
|
||||||
|
if (p === 'xray') return 'Xray';
|
||||||
|
if (p === 'xui') return '3x-ui VLESS';
|
||||||
|
return (p || '').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConnections() {
|
||||||
|
if (document.getElementById('loadingState') === null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/guest/${TOKEN}/connections`);
|
||||||
|
if (res.status === 401) return;
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById('loadingState').classList.add('hidden');
|
||||||
|
|
||||||
|
if (!data.connections || data.connections.length === 0) {
|
||||||
|
document.getElementById('emptyState').classList.remove('hidden');
|
||||||
|
document.getElementById('connectionsGrid').classList.add('hidden');
|
||||||
|
document.getElementById('connectionsGrid').innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('emptyState').classList.add('hidden');
|
||||||
|
const grid = document.getElementById('connectionsGrid');
|
||||||
|
grid.classList.remove('hidden');
|
||||||
|
grid.innerHTML = data.connections.map(c => `
|
||||||
|
<div class="card" style="padding: var(--space-md); border: 1px solid var(--border-color);">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: var(--space-sm);">
|
||||||
|
<div style="text-align: initial;">
|
||||||
|
<div style="font-weight: 600; font-size: 1.1rem;">${c.name}</div>
|
||||||
|
<div style="font-size: 0.8rem; color: var(--text-muted);">${protoLabel(c.protocol)} • ${c.server_name || ''}</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-success">${_('active')}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary btn-sm w-full" onclick="showConfig('${c.id}', '${(c.name || '').replace(/'/g, "\\'")}')">
|
||||||
|
${_('show_settings_btn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createGuestConfig() {
|
||||||
|
const btn = document.getElementById('createBtn');
|
||||||
|
const text = document.getElementById('createBtnText');
|
||||||
|
const spinner = document.getElementById('createSpinner');
|
||||||
|
btn.disabled = true;
|
||||||
|
text.classList.add('hidden');
|
||||||
|
spinner.classList.remove('hidden');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/guest/${TOKEN}/create`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: 'Guest VPN' })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || data.error) throw new Error(data.error || _('error'));
|
||||||
|
if (data.config) {
|
||||||
|
openConfigModal(data.connection?.name || 'Guest VPN', data.config, data.vpn_link || '');
|
||||||
|
}
|
||||||
|
await loadConnections();
|
||||||
|
document.getElementById('emptyState')?.classList.add('hidden');
|
||||||
|
} catch (err) {
|
||||||
|
alert(`${_('error')}: ` + err.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
text.classList.remove('hidden');
|
||||||
|
spinner.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchConfigTab(tab) {
|
||||||
|
document.querySelectorAll('.config-tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.config-panel').forEach(p => p.classList.remove('active'));
|
||||||
|
|
||||||
|
const tabs = document.querySelectorAll('.config-tab');
|
||||||
|
const panels = { conf: 'panel-conf', vpn: 'panel-vpn', qr: 'panel-qr' };
|
||||||
|
const tabIdx = { conf: 0, vpn: 1, qr: 2 };
|
||||||
|
|
||||||
|
tabs[tabIdx[tab]].classList.add('active');
|
||||||
|
document.getElementById(panels[tab]).classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentConfig = '';
|
||||||
|
let currentVpnLink = '';
|
||||||
|
|
||||||
|
function openConfigModal(name, config, vpnLink) {
|
||||||
|
currentConfig = config;
|
||||||
|
currentVpnLink = vpnLink || '';
|
||||||
|
document.getElementById('configText').value = config;
|
||||||
|
document.getElementById('vpnLinkText').textContent = currentVpnLink;
|
||||||
|
document.getElementById('configModalTitle').textContent = name;
|
||||||
|
const dl = document.getElementById('downloadBtn');
|
||||||
|
dl.onclick = () => downloadFile(config, `${name}.conf`);
|
||||||
|
const qrContainer = document.getElementById('qrcode');
|
||||||
|
qrContainer.innerHTML = '';
|
||||||
|
new QRCode(qrContainer, {
|
||||||
|
text: config,
|
||||||
|
width: 256,
|
||||||
|
height: 256,
|
||||||
|
colorDark: "#000000",
|
||||||
|
colorLight: "#ffffff",
|
||||||
|
correctLevel: QRCode.CorrectLevel.L
|
||||||
|
});
|
||||||
|
switchConfigTab('conf');
|
||||||
|
openModal('configModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showConfig(connId, name) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/guest/${TOKEN}/config/${connId}`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) throw new Error(data.error);
|
||||||
|
openConfigModal(name, data.config, data.vpn_link || '');
|
||||||
|
} catch (err) {
|
||||||
|
alert(`${_('error')}: ` + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyConfig() {
|
||||||
|
copyToClipboard(currentConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyVpnLink() {
|
||||||
|
copyToClipboard(currentVpnLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', loadConnections);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.w-full { width: 100%; }
|
||||||
|
.spinner {
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-top: 3px solid var(--accent-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@@ -131,6 +131,15 @@
|
|||||||
<div class="spinner hidden" id="loginSpinner"></div>
|
<div class="spinner hidden" id="loginSpinner"></div>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{% if guest_link %}
|
||||||
|
<div style="margin-top: var(--space-lg); text-align: center; border-top: 1px solid var(--border-color); padding-top: var(--space-md);">
|
||||||
|
<a href="{{ guest_link }}" class="btn btn-secondary" style="width:100%; text-decoration:none;">
|
||||||
|
{{ _('guest_access_btn') }}
|
||||||
|
</a>
|
||||||
|
<div class="form-hint" style="margin-top: var(--space-xs);">{{ _('guest_access_login_hint') }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+210
-3
@@ -45,6 +45,97 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- BLOCK Guest Access -->
|
||||||
|
<div class="card">
|
||||||
|
<h3 class="card-title" style="margin-bottom: var(--space-lg);">{{ _('guest_settings_title') }}</h3>
|
||||||
|
<form id="guestForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
|
||||||
|
<input type="checkbox" id="guest_enabled" {% if settings.guest.enabled %}checked{% endif %}
|
||||||
|
onchange="document.getElementById('guestFields').style.display = this.checked ? 'block' : 'none'">
|
||||||
|
{{ _('guest_enable') }}
|
||||||
|
</label>
|
||||||
|
<div class="form-hint">{{ _('guest_enable_hint') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="guestFields" style="{% if not settings.guest.enabled %}display:none;{% endif %}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{{ _('guest_link_label') }}</label>
|
||||||
|
<div style="display:flex; gap: var(--space-sm);">
|
||||||
|
<input type="text" class="form-input" id="guest_link_display" readonly
|
||||||
|
value="{% if settings.guest.token %}{{ request.url.scheme }}://{{ request.url.netloc }}/guest/{{ settings.guest.token }}{% endif %}"
|
||||||
|
placeholder="{{ _('guest_link_placeholder') }}">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" onclick="copyGuestLink()">📋</button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" onclick="regenGuestToken()" title="{{ _('guest_regen_token') }}">🔄</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="guest_token" value="{{ settings.guest.token or '' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{{ _('guest_user_label') }}</label>
|
||||||
|
<select class="form-select" id="guest_user_id">
|
||||||
|
<option value="">{{ _('guest_user_none') }}</option>
|
||||||
|
{% for u in users %}
|
||||||
|
<option value="{{ u.id }}" {% if settings.guest.user_id == u.id %}selected{% endif %}>
|
||||||
|
{{ u.username }} ({{ u.role }})
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="form-hint">{{ _('guest_user_hint') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{{ _('guest_password_label') }}</label>
|
||||||
|
<input type="password" class="form-input" id="guest_password"
|
||||||
|
placeholder="{% if settings.guest.password_hash %}{{ _('guest_password_keep') }}{% else %}{{ _('share_password_hint') }}{% endif %}"
|
||||||
|
autocomplete="new-password">
|
||||||
|
{% if settings.guest.password_hash %}
|
||||||
|
<label style="display:flex; align-items:center; gap:var(--space-sm); margin-top:var(--space-xs); cursor:pointer;">
|
||||||
|
<input type="checkbox" id="guest_clear_password">
|
||||||
|
{{ _('guest_clear_password') }}
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
|
||||||
|
<input type="checkbox" id="guest_allow_create" {% if settings.guest.allow_create %}checked{% endif %}
|
||||||
|
onchange="document.getElementById('guestCreateFields').style.display = this.checked ? 'block' : 'none'">
|
||||||
|
{{ _('guest_allow_create') }}
|
||||||
|
</label>
|
||||||
|
<div class="form-hint">{{ _('guest_allow_create_hint') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="guestCreateFields"
|
||||||
|
style="{% if not settings.guest.allow_create %}display:none;{% endif %} background: var(--bg-primary); padding: var(--space-md); border-radius: var(--radius-md);">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{{ _('protocol_label') }}</label>
|
||||||
|
<select class="form-select" id="guest_create_protocol" onchange="updateGuestCreateFields()">
|
||||||
|
<option value="xui" {% if settings.guest.create_protocol == 'xui' %}selected{% endif %}>3x-ui VLESS</option>
|
||||||
|
{% for s in servers %}
|
||||||
|
{% set server_idx = loop.index0 %}
|
||||||
|
{% for key, info in (s.protocols or {}).items() %}
|
||||||
|
{% if info.installed and key.split('__')[0] in ['awg','awg2','awg_legacy','xray','wireguard','telemt'] %}
|
||||||
|
<option value="{{ key }}|{{ server_idx }}"
|
||||||
|
{% if settings.guest.create_protocol == key and settings.guest.create_server_id == server_idx %}selected{% endif %}>
|
||||||
|
{{ s.name or s.host }} — {{ key }}
|
||||||
|
</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="guestInboundGroup" style="{% if settings.guest.create_protocol != 'xui' %}display:none;{% endif %}">
|
||||||
|
<label class="form-label">{{ _('xui_inbound_label') }}</label>
|
||||||
|
<select class="form-select" id="guest_create_inbound_id">
|
||||||
|
<option value="0">{{ _('xui_inbound_auto') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- BLOCK Telegram Bot -->
|
<!-- BLOCK Telegram Bot -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3 class="card-title" style="margin-bottom: var(--space-lg);">{{ _('telegram_bot_title') }}</h3>
|
<h3 class="card-title" style="margin-bottom: var(--space-lg);">{{ _('telegram_bot_title') }}</h3>
|
||||||
@@ -394,6 +485,14 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{{ _('xui_inbound_label') }}</label>
|
||||||
|
<select class="form-select" name="xui_inbound_id" id="xuiInboundSelect">
|
||||||
|
<option value="0">{{ _('xui_inbound_auto') }}</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-hint">{{ _('xui_inbound_hint') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="xuiAutoConnFields"
|
<div id="xuiAutoConnFields"
|
||||||
style="{% if not settings.sync.xui_create_conns %}display:none;{% endif %} background: var(--bg-primary); padding: var(--space-md); border-radius: var(--radius-md); margin-top: var(--space-sm);">
|
style="{% if not settings.sync.xui_create_conns %}display:none;{% endif %} background: var(--bg-primary); padding: var(--space-md); border-radius: var(--radius-md); margin-top: var(--space-sm);">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -616,8 +715,39 @@
|
|||||||
|
|
||||||
document.querySelector('[name="xui_sync"]').addEventListener('change', (e) => {
|
document.querySelector('[name="xui_sync"]').addEventListener('change', (e) => {
|
||||||
document.getElementById('xuiFields').style.display = e.target.checked ? 'block' : 'none';
|
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 = `<option value="0">${_('xui_inbound_auto')}</option>`;
|
||||||
|
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('syncCreateConns').addEventListener('change', (e) => {
|
||||||
document.getElementById('autoConnFields').style.display = e.target.checked ? 'block' : 'none';
|
document.getElementById('autoConnFields').style.display = e.target.checked ? 'block' : 'none';
|
||||||
if (e.target.checked) updateProtocolsForSync();
|
if (e.target.checked) updateProtocolsForSync();
|
||||||
@@ -897,7 +1027,8 @@
|
|||||||
xui_api_token: syncForm.xui_api_token.value.trim(),
|
xui_api_token: syncForm.xui_api_token.value.trim(),
|
||||||
xui_create_conns: syncForm.xui_create_conns.checked,
|
xui_create_conns: syncForm.xui_create_conns.checked,
|
||||||
xui_server_id: parseInt(syncForm.xui_server_id.value || '0'),
|
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', {
|
const res = await fetch('/api/settings/xui_sync_now', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -978,7 +1109,8 @@
|
|||||||
xui_sync_users: syncForm.xui_sync_users.checked,
|
xui_sync_users: syncForm.xui_sync_users.checked,
|
||||||
xui_create_conns: syncForm.xui_create_conns.checked,
|
xui_create_conns: syncForm.xui_create_conns.checked,
|
||||||
xui_server_id: parseInt(syncForm.xui_server_id.value || '0'),
|
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')
|
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 {
|
try {
|
||||||
const res = await fetch('/api/settings/save', {
|
const res = await fetch('/api/settings/save', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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'));
|
if (!res.ok) throw new Error(_('error'));
|
||||||
showToast(_('settings_saved'), 'success');
|
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 = `<option value="0">${_('xui_inbound_auto')}</option>`;
|
||||||
|
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() {
|
async function toggleBot() {
|
||||||
const btn = document.getElementById('toggleBotBtn');
|
const btn = document.getElementById('toggleBotBtn');
|
||||||
const text = document.getElementById('toggleBotBtnText');
|
const text = document.getElementById('toggleBotBtnText');
|
||||||
|
|||||||
+60
-10
@@ -291,6 +291,14 @@
|
|||||||
<div class="form-hint">{{ _('existing_conn_hint') }}</div>
|
<div class="form-hint">{{ _('existing_conn_hint') }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="ucXuiInboundGroup" style="display:none;">
|
||||||
|
<label class="form-label">{{ _('xui_inbound_label') }}</label>
|
||||||
|
<select class="form-select" id="ucXuiInbound">
|
||||||
|
<option value="">{{ _('xui_inbound_auto') }}</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-hint">{{ _('xui_inbound_hint') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="ucNameGroup">
|
<div class="form-group" id="ucNameGroup">
|
||||||
<label class="form-label">{{ _('conn_name_panel') }}</label>
|
<label class="form-label">{{ _('conn_name_panel') }}</label>
|
||||||
<input class="form-input" type="text" id="ucName" placeholder="{{ _('connection_name_placeholder') }}">
|
<input class="form-input" type="text" id="ucName" placeholder="{{ _('connection_name_placeholder') }}">
|
||||||
@@ -397,6 +405,8 @@
|
|||||||
let searchTimeout = null;
|
let searchTimeout = null;
|
||||||
|
|
||||||
const serversData = {{ servers | tojson }};
|
const serversData = {{ servers | tojson }};
|
||||||
|
const xuiConfigured = {{ 'true' if xui_configured else 'false' }};
|
||||||
|
const xuiDefaultInboundId = {{ xui_inbound_id | int }};
|
||||||
|
|
||||||
function populateTimeSelect(selectId) {
|
function populateTimeSelect(selectId) {
|
||||||
const select = document.getElementById(selectId);
|
const select = document.getElementById(selectId);
|
||||||
@@ -587,16 +597,11 @@
|
|||||||
const group = groupId ? document.getElementById(groupId) : null;
|
const group = groupId ? document.getElementById(groupId) : null;
|
||||||
select.innerHTML = '';
|
select.innerHTML = '';
|
||||||
const vpnBases = new Set(['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard']);
|
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;
|
let count = 0;
|
||||||
|
|
||||||
|
const server = (serverId !== '' && serversData[serverId]) ? serversData[serverId] : null;
|
||||||
|
const protocols = (server && server.protocols) || {};
|
||||||
|
|
||||||
for (const [key, info] of Object.entries(protocols)) {
|
for (const [key, info] of Object.entries(protocols)) {
|
||||||
if (!info.installed) continue;
|
if (!info.installed) continue;
|
||||||
const base = key.split('__')[0];
|
const base = key.split('__')[0];
|
||||||
@@ -608,6 +613,14 @@
|
|||||||
count++;
|
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 (count > 0) {
|
||||||
if (group) group.style.display = '';
|
if (group) group.style.display = '';
|
||||||
} else {
|
} else {
|
||||||
@@ -617,6 +630,39 @@
|
|||||||
select.appendChild(opt);
|
select.appendChild(opt);
|
||||||
if (group) group.style.display = '';
|
if (group) group.style.display = '';
|
||||||
}
|
}
|
||||||
|
if (selectId === 'ucProtocol') {
|
||||||
|
updateXuiInboundVisibility();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadXuiInbounds() {
|
||||||
|
const select = document.getElementById('ucXuiInbound');
|
||||||
|
if (!select) return;
|
||||||
|
select.innerHTML = `<option value="">${_('xui_inbound_auto')}</option>`;
|
||||||
|
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
|
// Show/hide connection fields
|
||||||
@@ -649,6 +695,7 @@
|
|||||||
fetchExistingClients();
|
fetchExistingClients();
|
||||||
}
|
}
|
||||||
updateTelemtOptions('ucProtocol', 'ucTelemtOptions');
|
updateTelemtOptions('ucProtocol', 'ucTelemtOptions');
|
||||||
|
updateXuiInboundVisibility();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function addUser(e) {
|
async function addUser(e) {
|
||||||
@@ -806,7 +853,7 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = {
|
const body = {
|
||||||
server_id: parseInt(document.getElementById('ucServer').value),
|
server_id: parseInt(document.getElementById('ucServer').value) || 0,
|
||||||
protocol: document.getElementById('ucProtocol').value,
|
protocol: document.getElementById('ucProtocol').value,
|
||||||
name: document.getElementById('ucName').value || 'VPN Connection',
|
name: document.getElementById('ucName').value || 'VPN Connection',
|
||||||
};
|
};
|
||||||
@@ -815,6 +862,9 @@
|
|||||||
const clientId = document.getElementById('ucExistingClient').value;
|
const clientId = document.getElementById('ucExistingClient').value;
|
||||||
if (!clientId) throw new Error(_('select_connection'));
|
if (!clientId) throw new Error(_('select_connection'));
|
||||||
body.client_id = clientId;
|
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') {
|
} else if (body.protocol === 'telemt') {
|
||||||
body.telemt_quota = document.getElementById('ucTelemtQuota').value || null;
|
body.telemt_quota = document.getElementById('ucTelemtQuota').value || null;
|
||||||
body.telemt_max_ips = parseInt(document.getElementById('ucTelemtMaxIps').value) || null;
|
body.telemt_max_ips = parseInt(document.getElementById('ucTelemtMaxIps').value) || null;
|
||||||
@@ -870,7 +920,7 @@
|
|||||||
<div class="client-name">${c.name || 'Connection'}</div>
|
<div class="client-name">${c.name || 'Connection'}</div>
|
||||||
<div class="client-meta">
|
<div class="client-meta">
|
||||||
<span>🖥 ${c.server_name || ''}</span>
|
<span>🖥 ${c.server_name || ''}</span>
|
||||||
<span>${c.protocol === 'awg' ? 'AmneziaWG' : (c.protocol === 'awg2' ? 'AmneziaWG 2.0' : (c.protocol === 'awg_legacy' ? 'AWG Legacy' : (c.protocol === 'xray' ? 'Xray' : c.protocol.toUpperCase())))}</span>
|
<span>${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()))))}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -170,6 +170,32 @@
|
|||||||
"enable_public_access": "Enable public access",
|
"enable_public_access": "Enable public access",
|
||||||
"share_password_label": "Password (optional)",
|
"share_password_label": "Password (optional)",
|
||||||
"share_password_hint": "If set, user must enter it",
|
"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",
|
"public_link_label": "Public link",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"out_of": "Of",
|
"out_of": "Of",
|
||||||
@@ -221,6 +247,9 @@
|
|||||||
"xui_username_label": "3x-ui Username",
|
"xui_username_label": "3x-ui Username",
|
||||||
"xui_password_label": "3x-ui Password",
|
"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_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",
|
"sync_now_btn": "🔄 Sync now",
|
||||||
"delete_sync_btn": "🗑 Delete synchronization",
|
"delete_sync_btn": "🗑 Delete synchronization",
|
||||||
"auto_create_conns": "Create connections automatically",
|
"auto_create_conns": "Create connections automatically",
|
||||||
|
|||||||
@@ -168,6 +168,32 @@
|
|||||||
"enable_public_access": "فعالسازی دسترسی عمومی",
|
"enable_public_access": "فعالسازی دسترسی عمومی",
|
||||||
"share_password_label": "رمز عبور (اختیاری)",
|
"share_password_label": "رمز عبور (اختیاری)",
|
||||||
"share_password_hint": "در صورت تنظیم، کاربر باید آن را وارد کند",
|
"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": "لینک عمومی",
|
"public_link_label": "لینک عمومی",
|
||||||
"disabled": "غیرفعال",
|
"disabled": "غیرفعال",
|
||||||
"out_of": "از",
|
"out_of": "از",
|
||||||
@@ -219,6 +245,9 @@
|
|||||||
"xui_username_label": "نام کاربری 3x-ui",
|
"xui_username_label": "نام کاربری 3x-ui",
|
||||||
"xui_password_label": "رمز عبور 3x-ui",
|
"xui_password_label": "رمز عبور 3x-ui",
|
||||||
"xui_sync_hint": "کلاینتهای 3x-ui بر اساس email به کاربران پنل همگام میشوند",
|
"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": "🔄 همگامسازی اکنون",
|
"sync_now_btn": "🔄 همگامسازی اکنون",
|
||||||
"delete_sync_btn": "🗑 حذف همگامسازی",
|
"delete_sync_btn": "🗑 حذف همگامسازی",
|
||||||
"auto_create_conns": "ایجاد خودکار اتصالها",
|
"auto_create_conns": "ایجاد خودکار اتصالها",
|
||||||
|
|||||||
@@ -168,6 +168,32 @@
|
|||||||
"enable_public_access": "Activer l'accès public",
|
"enable_public_access": "Activer l'accès public",
|
||||||
"share_password_label": "Mot de passe (optionnel)",
|
"share_password_label": "Mot de passe (optionnel)",
|
||||||
"share_password_hint": "Si défini, requis pour l'accès",
|
"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",
|
"public_link_label": "Lien public",
|
||||||
"disabled": "Désactivé",
|
"disabled": "Désactivé",
|
||||||
"out_of": "sur",
|
"out_of": "sur",
|
||||||
@@ -219,6 +245,9 @@
|
|||||||
"xui_username_label": "Identifiant 3x-ui",
|
"xui_username_label": "Identifiant 3x-ui",
|
||||||
"xui_password_label": "Mot de passe 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_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",
|
"sync_now_btn": "🔄 Sync maintenant",
|
||||||
"delete_sync_btn": "🗑 Supprimer synchro",
|
"delete_sync_btn": "🗑 Supprimer synchro",
|
||||||
"auto_create_conns": "Créer connexions auto",
|
"auto_create_conns": "Créer connexions auto",
|
||||||
|
|||||||
@@ -170,6 +170,32 @@
|
|||||||
"enable_public_access": "Включить публичный доступ",
|
"enable_public_access": "Включить публичный доступ",
|
||||||
"share_password_label": "Пароль (опционально)",
|
"share_password_label": "Пароль (опционально)",
|
||||||
"share_password_hint": "Если установлен, пользователь должен будет его ввести",
|
"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": "Публичная ссылка",
|
"public_link_label": "Публичная ссылка",
|
||||||
"disabled": "Отключён",
|
"disabled": "Отключён",
|
||||||
"out_of": "Из",
|
"out_of": "Из",
|
||||||
@@ -221,6 +247,9 @@
|
|||||||
"xui_username_label": "Логин 3x-ui",
|
"xui_username_label": "Логин 3x-ui",
|
||||||
"xui_password_label": "Пароль 3x-ui",
|
"xui_password_label": "Пароль 3x-ui",
|
||||||
"xui_sync_hint": "Клиенты из inbounds 3x-ui будут создаваться, отключаться и удаляться как пользователи панели (связь по email)",
|
"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": "🔄 Синхронизировать сейчас",
|
"sync_now_btn": "🔄 Синхронизировать сейчас",
|
||||||
"delete_sync_btn": "🗑 Удалить синхронизацию",
|
"delete_sync_btn": "🗑 Удалить синхронизацию",
|
||||||
"auto_create_conns": "Создавать подключения автоматически",
|
"auto_create_conns": "Создавать подключения автоматически",
|
||||||
|
|||||||
@@ -168,6 +168,32 @@
|
|||||||
"enable_public_access": "启用公开访问",
|
"enable_public_access": "启用公开访问",
|
||||||
"share_password_label": "密码 (可选)",
|
"share_password_label": "密码 (可选)",
|
||||||
"share_password_hint": "如设置,访问者需输入密码",
|
"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": "公开链接",
|
"public_link_label": "公开链接",
|
||||||
"disabled": "已禁用",
|
"disabled": "已禁用",
|
||||||
"out_of": "/",
|
"out_of": "/",
|
||||||
@@ -219,6 +245,9 @@
|
|||||||
"xui_username_label": "3x-ui 用户名",
|
"xui_username_label": "3x-ui 用户名",
|
||||||
"xui_password_label": "3x-ui 密码",
|
"xui_password_label": "3x-ui 密码",
|
||||||
"xui_sync_hint": "3x-ui 客户端将按 email 同步为面板用户",
|
"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": "🔄 立即同步",
|
"sync_now_btn": "🔄 立即同步",
|
||||||
"delete_sync_btn": "🗑 删除同步数据",
|
"delete_sync_btn": "🗑 删除同步数据",
|
||||||
"auto_create_conns": "自动创建连接",
|
"auto_create_conns": "自动创建连接",
|
||||||
|
|||||||
Reference in New Issue
Block a user