Remove 3x-ui integration from the panel.

Drop API sync, multi-panel registry, and UI/protocol paths; keep DB columns only for compatibility with existing installs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-26 04:43:21 +03:00
co-authored by Cursor
parent 2707e0af18
commit 30ae4d476c
14 changed files with 77 additions and 2281 deletions
+26 -718
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -81,7 +81,7 @@ CREATE TABLE IF NOT EXISTS invite_links (
max_uses INTEGER NOT NULL DEFAULT 1, max_uses INTEGER NOT NULL DEFAULT 1,
used_count INTEGER NOT NULL DEFAULT 0, used_count INTEGER NOT NULL DEFAULT 0,
user_id UUID, user_id UUID,
protocol TEXT NOT NULL DEFAULT 'xui', protocol TEXT NOT NULL DEFAULT 'awg',
server_id INTEGER NOT NULL DEFAULT 0, server_id INTEGER NOT NULL DEFAULT 0,
xui_inbound_id INTEGER NOT NULL DEFAULT 0, xui_inbound_id INTEGER NOT NULL DEFAULT 0,
xui_panel_id TEXT NOT NULL DEFAULT '', xui_panel_id TEXT NOT NULL DEFAULT '',
+3 -23
View File
@@ -35,29 +35,15 @@ DEFAULT_SETTINGS = {
'remnawave_create_conns': False, 'remnawave_create_conns': False,
'remnawave_server_id': 0, 'remnawave_server_id': 0,
'remnawave_protocol': 'awg', 'remnawave_protocol': 'awg',
'xui_sync': False,
'xui_url': '',
'xui_username': '',
'xui_password': '',
'xui_api_token': '',
'xui_sync_users': False,
'xui_create_conns': False,
'xui_server_id': 0,
'xui_protocol': 'xray',
'xui_inbound_id': 0,
'xui_sub_url': '',
}, },
'xui_servers': [],
'guest': { 'guest': {
'enabled': False, 'enabled': False,
'token': '', 'token': '',
'password_hash': None, 'password_hash': None,
'user_id': '', 'user_id': '',
'allow_create': False, 'allow_create': False,
'create_protocol': 'xui', 'create_protocol': 'awg',
'create_server_id': 0, 'create_server_id': 0,
'create_inbound_id': 0,
'create_xui_panel_id': '',
}, },
} }
@@ -105,12 +91,6 @@ def _merge_settings(raw: Optional[dict]) -> dict:
for key, value in raw.items(): for key, value in raw.items():
if key not in settings: if key not in settings:
settings[key] = value settings[key] = value
# Multi 3x-ui panels: migrate legacy sync.xui_* if needed
try:
from managers.xui_servers import ensure_xui_servers
ensure_xui_servers(settings)
except Exception:
settings.setdefault('xui_servers', [])
return settings return settings
@@ -214,7 +194,7 @@ def _row_to_invite(row) -> dict:
'max_uses': int(row['max_uses'] or 0), 'max_uses': int(row['max_uses'] or 0),
'used_count': int(row['used_count'] or 0), 'used_count': int(row['used_count'] or 0),
'user_id': str(row['user_id']) if row['user_id'] else '', 'user_id': str(row['user_id']) if row['user_id'] else '',
'protocol': row['protocol'] or 'xui', 'protocol': row['protocol'] or 'awg',
'server_id': int(row['server_id'] or 0), 'server_id': int(row['server_id'] or 0),
'xui_inbound_id': int(row['xui_inbound_id'] or 0), 'xui_inbound_id': int(row['xui_inbound_id'] or 0),
'xui_panel_id': (row.get('xui_panel_id') or '') if hasattr(row, 'get') else '', 'xui_panel_id': (row.get('xui_panel_id') or '') if hasattr(row, 'get') else '',
@@ -415,7 +395,7 @@ def save_data(data: dict) -> None:
int(link.get('max_uses') or 0), int(link.get('max_uses') or 0),
int(link.get('used_count') or 0), int(link.get('used_count') or 0),
_as_uuid(uid) if uid else None, _as_uuid(uid) if uid else None,
link.get('protocol') or 'xui', link.get('protocol') or 'awg',
int(link.get('server_id') or 0), int(link.get('server_id') or 0),
int(link.get('xui_inbound_id') or 0), int(link.get('xui_inbound_id') or 0),
link.get('xui_panel_id') or '', link.get('xui_panel_id') or '',
-561
View File
@@ -1,561 +0,0 @@
"""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'),
'sub_url': (sync.get('xui_sub_url') or '').strip().rstrip('/'),
}
def build_subscription_url(settings: dict, sub_id: str) -> str:
"""Build public subscription URL from panel settings + client subId."""
sub_id = (sub_id or '').strip()
if not sub_id:
return ''
base = _settings_creds(settings).get('sub_url') or ''
if not base:
return ''
return f"{base}/{sub_id}"
def _resolve_settings(settings: dict, panel_id: Optional[str] = None) -> dict:
"""Scope settings to a specific xui panel when panel_id / multi-server registry is used."""
try:
from managers.xui_servers import resolve_panel_settings, ensure_xui_servers
ensure_xui_servers(settings)
return resolve_panel_settings(settings, panel_id)
except Exception:
return settings
class XuiApiError(RuntimeError):
pass
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 get_inbound(self, inbound_id: int) -> dict:
inbound_id = int(inbound_id)
for inbound in await self.list_inbounds():
if isinstance(inbound, dict) and int(inbound.get('id') or 0) == inbound_id:
return inbound
raise XuiApiError(f'Inbound #{inbound_id} not found on 3x-ui')
@staticmethod
def _parse_inbound_settings(inbound: dict) -> dict:
settings_raw = inbound.get('settings') or '{}'
if isinstance(settings_raw, str):
try:
settings_obj = json.loads(settings_raw)
except Exception:
settings_obj = {}
else:
settings_obj = settings_raw if isinstance(settings_raw, dict) else {}
return settings_obj if isinstance(settings_obj, dict) else {}
def _client_template_from_inbound(self, inbound: dict) -> dict:
"""Copy only fields 3x-ui already uses on this inbound — do not invent extras."""
settings_obj = self._parse_inbound_settings(inbound)
clients = settings_obj.get('clients') or []
template = next((c for c in clients if isinstance(c, dict)), None) or {}
flow = template.get('flow')
if not isinstance(flow, str):
flow = ''
# If no existing clients, detect Reality → vision (required for those inbounds)
if not flow:
stream_raw = inbound.get('streamSettings') or '{}'
if isinstance(stream_raw, str):
try:
stream = json.loads(stream_raw)
except Exception:
stream = {}
else:
stream = stream_raw if isinstance(stream_raw, dict) else {}
security = (stream.get('security') or '').lower()
network = (stream.get('network') or '').lower()
if security == 'reality' and network in ('tcp', 'raw', ''):
flow = 'xtls-rprx-vision'
return {
'flow': flow,
'limitIp': int(template.get('limitIp') or 0),
'totalGB': 0,
'tgId': '' if template.get('tgId') in (None, 0) else str(template.get('tgId') or ''),
}
async def add_vless_client(
self,
*,
email: str,
inbound_id: int,
enable: bool = True,
expiry_time: int = 0,
) -> dict:
"""Add client via 3x-ui addClient only. Minimal payload; flow taken from inbound."""
email = (email or '').strip()
if not email:
raise XuiApiError('Client email is required')
if not inbound_id:
raise XuiApiError('VLESS inbound id is required')
inbound = await self.get_inbound(int(inbound_id))
if (inbound.get('protocol') or '').lower() != 'vless':
raise XuiApiError(f'Inbound #{inbound_id} is not VLESS')
tmpl = self._client_template_from_inbound(inbound)
client_uuid = str(uuid.uuid4())
sub_id = _new_sub_id()
# Minimal client object — nothing beyond what 3x-ui expects for this inbound
client = {
'id': client_uuid,
'email': email,
'enable': bool(enable),
'flow': tmpl['flow'],
'limitIp': tmpl['limitIp'],
'totalGB': 0,
'expiryTime': int(expiry_time or 0),
'tgId': tmpl['tgId'],
'subId': sub_id,
}
# Official path: addClient with settings as JSON string (object form breaks some builds)
await self._request(
'POST',
'/panel/api/inbounds/addClient',
json={
'id': int(inbound_id),
'settings': json.dumps({'clients': [client]}),
},
)
# Share links ONLY from the panel — never build vless:// here
links = await self.get_client_links(email)
if not links:
links = await self.get_sub_links(sub_id)
return {
'client_id': email,
'uuid': client_uuid,
'sub_id': sub_id,
'email': email,
'config': '',
'links': links,
'expiry_time': int(expiry_time or 0),
'inbound_id': int(inbound_id),
}
async def get_client_links(self, email: str) -> list:
email = (email or '').strip()
if not email:
return []
for path in (
f'/panel/api/clients/links/{quote(email, safe="")}',
f'/panel/api/inbounds/clientLinks/0/{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 %s failed: %s', path, e)
return []
async def get_sub_links(self, sub_id: str) -> list:
"""Ask 3x-ui for protocol URLs produced for this subscription id."""
sub_id = (sub_id or '').strip()
if not sub_id:
return []
path = f'/panel/api/inbounds/getSubLinks/{quote(sub_id, 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('getSubLinks failed: %s', e)
return []
async def get_panel_sub_base(self) -> str:
"""Try to read public subscription base URL from 3x-ui panel settings."""
for path, method in (
('/panel/api/setting', 'GET'),
('/panel/setting/all', 'POST'),
('/panel/api/settings', 'GET'),
):
try:
payload = await self._request(method, path)
except XuiApiError:
continue
obj = payload.get('obj') if isinstance(payload, dict) else payload
if not isinstance(obj, dict):
continue
# Common field names across 3x-ui versions
sub_uri = (obj.get('subURI') or obj.get('subUri') or obj.get('sub_uri') or '').strip()
sub_path = (obj.get('subPath') or obj.get('sub_path') or '/sub').strip() or '/sub'
sub_port = obj.get('subPort') or obj.get('sub_port')
sub_listen = (obj.get('subListen') or '').strip()
if sub_uri:
return sub_uri.rstrip('/')
# Build from panel host + sub path if only path is configured
if sub_path and self.base_url:
from urllib.parse import urlparse, urlunparse
parsed = urlparse(self.base_url)
host = sub_listen or parsed.hostname or ''
if not host:
continue
scheme = 'https' if parsed.scheme == 'https' else 'http'
netloc = f'{host}:{sub_port}' if sub_port else host
path_part = sub_path if sub_path.startswith('/') else f'/{sub_path}'
return urlunparse((scheme, netloc, path_part.rstrip('/'), '', '', ''))
return ''
async def resolve_share_link(self, *, email: str, sub_id: str = '', configured_sub_base: str = '') -> dict:
"""Use only links returned by 3x-ui. Do not synthesize protocol configs."""
email = (email or '').strip()
sub_id = (sub_id or '').strip()
links: list = []
if email:
links = await self.get_client_links(email)
if not links and sub_id:
links = await self.get_sub_links(sub_id)
# Prefer protocol share from panel; then HTTP(S) subscription URL from panel
panel_proto = next(
(u for u in links if isinstance(u, str) and u.startswith(('vless://', 'vmess://', 'trojan://', 'ss://'))),
'',
)
panel_http = next(
(u for u in links if isinstance(u, str) and u.startswith(('http://', 'https://'))),
'',
)
# Subscription URL: panel HTTP link first; optional configured base only as last resort
base = (configured_sub_base or '').strip().rstrip('/')
if not panel_http and not base:
try:
base = (await self.get_panel_sub_base() or '').rstrip('/')
except Exception as e:
logger.warning('get_panel_sub_base failed: %s', e)
base = ''
subscription_url = panel_http or (f'{base}/{sub_id}' if base and sub_id else '')
# Prefer panel-generated protocol link (real config); else subscription URL
share = panel_proto or subscription_url or (links[0] if links else '')
return {
'subscription_url': subscription_url,
'links': links,
'share': share,
'sub_base': base,
}
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,
expiry_time: int = 0,
panel_id: Optional[str] = None,
) -> dict:
"""Create client on selected 3x-ui inbound; return only links from the panel."""
scoped = _resolve_settings(settings, panel_id)
creds = _settings_creds(scoped)
inbound = inbound_id if inbound_id not in (None, 0, '0') else creds.get('inbound_id')
try:
inbound = int(inbound or 0)
except (TypeError, ValueError):
inbound = 0
if not inbound:
raise XuiApiError('Select a VLESS inbound from 3x-ui')
async with XuiApi.from_panel_settings(scoped) as api:
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
created = None
for _ in range(5):
try:
created = await api.add_vless_client(
email=email,
inbound_id=inbound,
expiry_time=int(expiry_time or 0),
)
break
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
if created is None:
created = await api.add_vless_client(
email=email,
inbound_id=inbound,
expiry_time=int(expiry_time or 0),
)
share = await api.resolve_share_link(
email=created.get('email') or email,
sub_id=created.get('sub_id') or '',
configured_sub_base=creds.get('sub_url') or '',
)
panel_links = share.get('links') or created.get('links') or []
created['links'] = panel_links
created['subscription_url'] = share.get('subscription_url') or ''
# Prefer real vless:// (etc.) from 3x-ui; subscription URL only as fallback
created['config'] = share.get('share') or ''
if not created['config'] and panel_links:
created['config'] = panel_links[0]
created['inbound_id'] = int(created.get('inbound_id') or inbound)
created['panel_id'] = panel_id or ''
return created
async def xui_get_config(settings: dict, email: str, panel_id: Optional[str] = None) -> str:
scoped = _resolve_settings(settings, panel_id)
creds = _settings_creds(scoped)
async with XuiApi.from_panel_settings(scoped) as api:
sub_id = ''
try:
for inbound in await api.list_inbounds():
if not isinstance(inbound, dict):
continue
settings_raw = inbound.get('settings') or '{}'
if isinstance(settings_raw, str):
try:
settings_obj = json.loads(settings_raw)
except Exception:
settings_obj = {}
else:
settings_obj = settings_raw if isinstance(settings_raw, dict) else {}
for c in settings_obj.get('clients') or []:
if isinstance(c, dict) and (c.get('email') or '') == email:
sub_id = (c.get('subId') or c.get('sub_id') or '').strip()
break
if sub_id:
break
except Exception as e:
logger.warning('Could not resolve subId for %s: %s', email, e)
share = await api.resolve_share_link(
email=email,
sub_id=sub_id,
configured_sub_base=creds.get('sub_url') or '',
)
if share.get('share'):
return share['share']
links = share.get('links') or await api.get_client_links(email)
vless = next((u for u in links if isinstance(u, str) and 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, panel_id: Optional[str] = None) -> None:
scoped = _resolve_settings(settings, panel_id)
async with XuiApi.from_panel_settings(scoped) as api:
await api.delete_client(email)
async def xui_toggle_client(settings: dict, email: str, enable: bool, panel_id: Optional[str] = None) -> None:
scoped = _resolve_settings(settings, panel_id)
async with XuiApi.from_panel_settings(scoped) as api:
await api.set_client_enabled(email, enable)
async def xui_list_vless_inbounds(settings: dict, panel_id: Optional[str] = None) -> list:
scoped = _resolve_settings(settings, panel_id)
async with XuiApi.from_panel_settings(scoped) as api:
return await api.list_vless_inbounds()
-214
View File
@@ -1,214 +0,0 @@
"""Multi-panel 3x-ui server registry stored in settings.xui_servers."""
from __future__ import annotations
import uuid
from typing import Any, Optional
def _new_id() -> str:
return str(uuid.uuid4())
def normalize_server(raw: dict | None) -> dict:
raw = raw or {}
return {
'id': str(raw.get('id') or _new_id()),
'name': (raw.get('name') or '').strip() or '3x-ui',
'url': (raw.get('url') or '').strip().rstrip('/'),
'sub_url': (raw.get('sub_url') or '').strip().rstrip('/'),
'api_token': (raw.get('api_token') or '').strip(),
'username': (raw.get('username') or '').strip(),
'password': raw.get('password') or '',
'default_inbound_id': int(raw.get('default_inbound_id') or 0),
'enabled': bool(raw.get('enabled', True)),
'sync_users': bool(raw.get('sync_users', False)),
}
def legacy_sync_to_server(sync: dict) -> Optional[dict]:
"""Build one xui_servers entry from old settings.sync.xui_* fields."""
sync = sync or {}
url = (sync.get('xui_url') or '').strip()
if not url:
return None
return normalize_server({
'id': 'legacy-default',
'name': '3x-ui (default)',
'url': url,
'sub_url': sync.get('xui_sub_url') or '',
'api_token': sync.get('xui_api_token') or '',
'username': sync.get('xui_username') or '',
'password': sync.get('xui_password') or '',
'default_inbound_id': int(sync.get('xui_inbound_id') or 0),
'enabled': True,
'sync_users': bool(sync.get('xui_sync_users')),
})
def ensure_xui_servers(settings: dict) -> list:
"""Ensure settings['xui_servers'] exists; migrate legacy sync fields once."""
settings = settings if isinstance(settings, dict) else {}
servers = settings.get('xui_servers')
if not isinstance(servers, list):
servers = []
servers = [normalize_server(s) for s in servers if isinstance(s, dict)]
if not servers:
migrated = legacy_sync_to_server(settings.get('sync') or {})
if migrated:
servers = [migrated]
settings['xui_servers'] = servers
# Mirror primary into legacy sync.* for older code paths (no get_xui_server — avoids recursion)
sync = settings.setdefault('sync', {})
primary = None
for s in servers:
if s.get('enabled'):
primary = s
break
if primary is None and servers:
primary = servers[0]
if primary and primary.get('url'):
sync.setdefault('xui_url', primary.get('url') or '')
sync.setdefault('xui_sub_url', primary.get('sub_url') or '')
sync.setdefault('xui_api_token', primary.get('api_token') or '')
sync.setdefault('xui_username', primary.get('username') or '')
sync.setdefault('xui_password', primary.get('password') or '')
sync.setdefault('xui_inbound_id', int(primary.get('default_inbound_id') or 0))
return servers
def list_xui_servers(settings: dict, *, enabled_only: bool = False) -> list:
servers = ensure_xui_servers(settings)
if enabled_only:
return [s for s in servers if s.get('enabled')]
return servers
def get_xui_server(settings: dict, panel_id: Optional[str] = None) -> Optional[dict]:
servers = ensure_xui_servers(settings)
if not servers:
return None
if panel_id:
for s in servers:
if str(s.get('id')) == str(panel_id):
return s
return None
for s in servers:
if s.get('enabled'):
return s
return servers[0]
def public_server_view(server: dict) -> dict:
"""Safe view for UI (no password/token secrets in list if desired — we keep them for admin edit)."""
return {
'id': server.get('id'),
'name': server.get('name') or '3x-ui',
'url': server.get('url') or '',
'sub_url': server.get('sub_url') or '',
'api_token': server.get('api_token') or '',
'username': server.get('username') or '',
'password': server.get('password') or '',
'default_inbound_id': int(server.get('default_inbound_id') or 0),
'enabled': bool(server.get('enabled', True)),
'sync_users': bool(server.get('sync_users', False)),
'has_token': bool(server.get('api_token')),
'has_password': bool(server.get('password')),
}
def upsert_xui_server(settings: dict, payload: dict, *, panel_id: Optional[str] = None) -> dict:
servers = ensure_xui_servers(settings)
panel_id = panel_id or payload.get('id')
existing = None
if panel_id:
for s in servers:
if str(s.get('id')) == str(panel_id):
existing = s
break
merged = dict(existing or {})
for key in ('name', 'url', 'sub_url', 'api_token', 'username', 'password'):
if key in payload and payload[key] is not None:
# Empty password/token on edit means "keep previous" when editing
if key in ('password', 'api_token') and existing and payload[key] == '':
continue
merged[key] = payload[key]
if 'default_inbound_id' in payload and payload['default_inbound_id'] is not None:
merged['default_inbound_id'] = int(payload['default_inbound_id'] or 0)
if 'enabled' in payload and payload['enabled'] is not None:
merged['enabled'] = bool(payload['enabled'])
if 'sync_users' in payload and payload['sync_users'] is not None:
merged['sync_users'] = bool(payload['sync_users'])
if not merged.get('id'):
merged['id'] = _new_id()
server = normalize_server(merged)
if not server['url']:
raise ValueError('3x-ui URL is required')
if not server['name']:
server['name'] = server['url']
if existing:
for i, s in enumerate(servers):
if str(s.get('id')) == str(existing.get('id')):
servers[i] = server
break
else:
servers.append(server)
settings['xui_servers'] = servers
_mirror_primary_to_sync(settings)
return server
def delete_xui_server(settings: dict, panel_id: str) -> bool:
servers = ensure_xui_servers(settings)
new_list = [s for s in servers if str(s.get('id')) != str(panel_id)]
if len(new_list) == len(servers):
return False
settings['xui_servers'] = new_list
_mirror_primary_to_sync(settings)
return True
def _mirror_primary_to_sync(settings: dict) -> None:
sync = settings.setdefault('sync', {})
primary = get_xui_server(settings, None)
if not primary:
sync['xui_url'] = ''
sync['xui_sub_url'] = ''
return
sync['xui_url'] = primary.get('url') or ''
sync['xui_sub_url'] = primary.get('sub_url') or ''
sync['xui_api_token'] = primary.get('api_token') or ''
sync['xui_username'] = primary.get('username') or ''
sync['xui_password'] = primary.get('password') or ''
sync['xui_inbound_id'] = int(primary.get('default_inbound_id') or 0)
if any(s.get('sync_users') for s in settings.get('xui_servers') or []):
sync['xui_sync_users'] = True
def server_to_settings_slice(server: dict) -> dict:
"""Shape expected by xui_api._settings_creds / from_panel_settings."""
return {
'sync': {
'xui_url': server.get('url') or '',
'xui_sub_url': server.get('sub_url') or '',
'xui_api_token': server.get('api_token') or '',
'xui_username': server.get('username') or '',
'xui_password': server.get('password') or '',
'xui_inbound_id': int(server.get('default_inbound_id') or 0),
}
}
def resolve_panel_settings(full_settings: dict, panel_id: Optional[str] = None) -> dict:
"""Return a settings-like dict scoped to one 3x-ui panel (fallback: primary / legacy)."""
server = get_xui_server(full_settings, panel_id)
if server:
return server_to_settings_slice(server)
# Fallback to legacy global sync
return {'sync': (full_settings or {}).get('sync') or {}}
-1
View File
@@ -139,7 +139,6 @@
if (p === 'awg2') return 'AmneziaWG 2.0'; if (p === 'awg2') return 'AmneziaWG 2.0';
if (p === 'awg_legacy') return 'AWG Legacy'; if (p === 'awg_legacy') return 'AWG Legacy';
if (p === 'xray') return 'Xray'; if (p === 'xray') return 'Xray';
if (p === 'xui') return '3x-ui VLESS';
return (p || '').toUpperCase(); return (p || '').toUpperCase();
} }
+4 -96
View File
@@ -17,18 +17,6 @@
</button> </button>
</div> </div>
{% if not xui_has_sub_url and xui_configured %}
<div class="card" style="margin-bottom:var(--space-lg); border-color: rgba(245, 158, 11, 0.35); background: rgba(245, 158, 11, 0.06);">
<div style="display:flex; gap:var(--space-md); align-items:flex-start;">
<div style="font-size:1.4rem;">⚠️</div>
<div>
<div style="font-weight:600; margin-bottom:4px;">{{ _('invite_sub_url_missing_title') }}</div>
<div class="form-hint" style="margin:0;">{{ _('invite_sub_url_missing_hint') }}</div>
</div>
</div>
</div>
{% endif %}
<div id="invitesEmpty" class="empty-state {% if invites %}hidden{% endif %}"> <div id="invitesEmpty" class="empty-state {% if invites %}hidden{% endif %}">
<div class="empty-icon">🔗</div> <div class="empty-icon">🔗</div>
<div class="empty-title">{{ _('invites_empty') }}</div> <div class="empty-title">{{ _('invites_empty') }}</div>
@@ -42,12 +30,7 @@
<div> <div>
<div style="font-weight:700; font-size:1.05rem;">{{ inv.name }}</div> <div style="font-weight:700; font-size:1.05rem;">{{ inv.name }}</div>
<div style="font-size:0.8rem; color:var(--text-muted); margin-top:2px;"> <div style="font-size:0.8rem; color:var(--text-muted); margin-top:2px;">
{% if inv.protocol == 'xui' %}
3x-ui · {% for s in xui_servers if s.id == inv.xui_panel_id %}{{ s.name }}{% else %}{{ _('xui_server_select_label') }}{% endfor %}
· inbound #{{ inv.xui_inbound_id or '—' }}
{% else %}
{{ inv.protocol }} {{ inv.protocol }}
{% endif %}
</div> </div>
</div> </div>
{% if inv.available %} {% if inv.available %}
@@ -127,8 +110,7 @@
<div class="form-group"> <div class="form-group">
<label class="form-label">{{ _('protocol_label') }}</label> <label class="form-label">{{ _('protocol_label') }}</label>
<select class="form-select" id="inviteProtocol" onchange="updateInviteProtoFields()"> <select class="form-select" id="inviteProtocol">
<option value="xui">3x-ui VLESS</option>
{% for s in servers %} {% for s in servers %}
{% set server_idx = loop.index0 %} {% set server_idx = loop.index0 %}
{% for key, info in (s.protocols or {}).items() %} {% for key, info in (s.protocols or {}).items() %}
@@ -140,21 +122,6 @@
</select> </select>
</div> </div>
<div class="form-group" id="inviteInboundGroup">
<label class="form-label">{{ _('xui_server_select_label') }}</label>
<select class="form-select" id="inviteXuiPanel" onchange="loadInviteInbounds()">
{% for s in xui_servers %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
<div class="form-hint">{{ _('xui_server_select_hint') }}</div>
<label class="form-label" style="margin-top:var(--space-sm);">{{ _('xui_inbound_label') }}</label>
<select class="form-select" id="inviteInboundId">
<option value="">{{ _('invite_inbound_loading') }}</option>
</select>
<div class="form-hint">{{ _('invite_inbound_hint') }}</div>
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label">{{ _('invite_password_label') }}</label> <label class="form-label">{{ _('invite_password_label') }}</label>
<input class="form-input" type="password" id="invitePassword" placeholder="{{ _('share_password_hint') }}" autocomplete="new-password"> <input class="form-input" type="password" id="invitePassword" placeholder="{{ _('share_password_hint') }}" autocomplete="new-password">
@@ -186,9 +153,6 @@
<script> <script>
const invitesData = {{ invites | tojson }}; const invitesData = {{ invites | tojson }};
const xuiConfigured = {{ 'true' if xui_configured else 'false' }};
const xuiDefaultInbound = {{ xui_default_inbound | int }};
const xuiDefaultPanelId = {{ (xui_default_panel_id or '') | tojson }};
function inviteUrl(token) { function inviteUrl(token) {
return `${window.location.origin}/invite/${token}`; return `${window.location.origin}/invite/${token}`;
@@ -199,46 +163,8 @@
showToast(_('copied'), 'success'); showToast(_('copied'), 'success');
} }
function updateInviteProtoFields() {
const v = document.getElementById('inviteProtocol').value;
document.getElementById('inviteInboundGroup').style.display = (v === 'xui') ? '' : 'none';
if (v === 'xui') loadInviteInbounds(document.getElementById('inviteInboundId').value || xuiDefaultInbound);
}
async function loadInviteInbounds(selected) {
const select = document.getElementById('inviteInboundId');
select.innerHTML = `<option value="">${_('invite_inbound_loading')}</option>`;
if (!xuiConfigured) {
select.innerHTML = `<option value="">${_('invite_inbound_need_xui')}</option>`;
return;
}
try {
const panelId = document.getElementById('inviteXuiPanel')?.value || xuiDefaultPanelId || '';
const q = panelId ? `?panel_id=${encodeURIComponent(panelId)}` : '';
const data = await apiCall('/api/settings/xui/inbounds' + q);
const list = data.inbounds || [];
if (!list.length) {
select.innerHTML = `<option value="">${_('invite_inbound_empty')}</option>`;
return;
}
select.innerHTML = '';
list.forEach((ib, idx) => {
const opt = document.createElement('option');
opt.value = ib.id;
opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`;
const prefer = selected || xuiDefaultInbound;
if (prefer && String(ib.id) === String(prefer)) opt.selected = true;
else if (!prefer && idx === 0) opt.selected = true;
select.appendChild(opt);
});
} catch (e) {
select.innerHTML = `<option value="">${_('error')}: ${e.message}</option>`;
}
}
function parseProtocolValue() { function parseProtocolValue() {
const raw = document.getElementById('inviteProtocol').value; const raw = document.getElementById('inviteProtocol').value;
if (raw === 'xui') return { protocol: 'xui', server_id: 0 };
if (raw.includes('|')) { if (raw.includes('|')) {
const [protocol, sid] = raw.split('|'); const [protocol, sid] = raw.split('|');
return { protocol, server_id: parseInt(sid || '0') }; return { protocol, server_id: parseInt(sid || '0') };
@@ -248,13 +174,9 @@
function setProtocolSelect(protocol, serverId) { function setProtocolSelect(protocol, serverId) {
const sel = document.getElementById('inviteProtocol'); const sel = document.getElementById('inviteProtocol');
if (protocol === 'xui') sel.value = 'xui';
else {
const want = `${protocol}|${serverId || 0}`; const want = `${protocol}|${serverId || 0}`;
if ([...sel.options].some(o => o.value === want)) sel.value = want; if ([...sel.options].some(o => o.value === want)) sel.value = want;
else sel.value = 'xui'; else if (sel.options.length) sel.value = sel.options[0].value;
}
updateInviteProtoFields();
} }
function openCreateInvite() { function openCreateInvite() {
@@ -268,12 +190,7 @@
document.getElementById('inviteNote').value = ''; document.getElementById('inviteNote').value = '';
document.getElementById('inviteClearPwdWrap').style.display = 'none'; document.getElementById('inviteClearPwdWrap').style.display = 'none';
document.getElementById('inviteResetUsedWrap').style.display = 'none'; document.getElementById('inviteResetUsedWrap').style.display = 'none';
setProtocolSelect('xui', 0); setProtocolSelect('awg', 0);
if (xuiDefaultPanelId) {
const p = document.getElementById('inviteXuiPanel');
if (p && [...p.options].some(o => o.value === xuiDefaultPanelId)) p.value = xuiDefaultPanelId;
}
loadInviteInbounds(xuiDefaultInbound);
openModal('inviteModal'); openModal('inviteModal');
} }
@@ -292,12 +209,7 @@
document.getElementById('inviteClearPassword').checked = false; document.getElementById('inviteClearPassword').checked = false;
document.getElementById('inviteResetUsedWrap').style.display = 'block'; document.getElementById('inviteResetUsedWrap').style.display = 'block';
document.getElementById('inviteResetUsed').checked = false; document.getElementById('inviteResetUsed').checked = false;
setProtocolSelect(inv.protocol || 'xui', inv.server_id || 0); setProtocolSelect(inv.protocol || 'awg', inv.server_id || 0);
const panelSel = document.getElementById('inviteXuiPanel');
if (panelSel && inv.xui_panel_id && [...panelSel.options].some(o => o.value === inv.xui_panel_id)) {
panelSel.value = inv.xui_panel_id;
}
loadInviteInbounds(inv.xui_inbound_id || xuiDefaultInbound);
openModal('inviteModal'); openModal('inviteModal');
} }
@@ -309,8 +221,6 @@
try { try {
const editId = document.getElementById('inviteEditId').value; const editId = document.getElementById('inviteEditId').value;
const proto = parseProtocolValue(); const proto = parseProtocolValue();
const inbound = parseInt(document.getElementById('inviteInboundId').value || '0');
if (proto.protocol === 'xui' && !inbound) throw new Error(_('invite_inbound_required'));
const body = { const body = {
name: document.getElementById('inviteName').value || 'Invite', name: document.getElementById('inviteName').value || 'Invite',
@@ -319,8 +229,6 @@
user_id: document.getElementById('inviteUserId').value || '', user_id: document.getElementById('inviteUserId').value || '',
protocol: proto.protocol, protocol: proto.protocol,
server_id: proto.server_id, server_id: proto.server_id,
xui_inbound_id: inbound,
xui_panel_id: document.getElementById('inviteXuiPanel')?.value || '',
note: document.getElementById('inviteNote').value || '', note: document.getElementById('inviteNote').value || '',
}; };
const pwd = document.getElementById('invitePassword').value; const pwd = document.getElementById('invitePassword').value;
+3 -404
View File
@@ -111,7 +111,6 @@
<div class="form-group"> <div class="form-group">
<label class="form-label">{{ _('protocol_label') }}</label> <label class="form-label">{{ _('protocol_label') }}</label>
<select class="form-select" id="guest_create_protocol" onchange="updateGuestCreateFields()"> <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 %} {% for s in servers %}
{% set server_idx = loop.index0 %} {% set server_idx = loop.index0 %}
{% for key, info in (s.protocols or {}).items() %} {% for key, info in (s.protocols or {}).items() %}
@@ -125,19 +124,6 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="form-group" id="guestInboundGroup" style="{% if settings.guest.create_protocol != 'xui' %}display:none;{% endif %}">
<label class="form-label">{{ _('xui_server_select_label') }}</label>
<select class="form-select" id="guest_create_xui_panel" onchange="loadGuestInbounds()">
{% for s in xui_servers %}
<option value="{{ s.id }}" {% if settings.guest.create_xui_panel_id == s.id %}selected{% endif %}>{{ s.name }}</option>
{% endfor %}
</select>
<label class="form-label" style="margin-top:var(--space-sm);">{{ _('xui_inbound_label') }}</label>
<select class="form-select" id="guest_create_inbound_id">
<option value="0">{{ _('invite_inbound_loading') }}</option>
</select>
<div class="form-hint">{{ _('xui_inbound_hint') }}</div>
</div>
</div> </div>
</div> </div>
</form> </form>
@@ -347,10 +333,6 @@
style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);"> style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);">
<input type="checkbox" disabled> Marzban <input type="checkbox" disabled> Marzban
</label> </label>
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
<input type="checkbox" name="xui_sync" {% if settings.sync.xui_sync %}checked{% endif %}>
3x-ui
</label>
<label <label
style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);"> style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);">
<input type="checkbox" disabled> Hiddify <input type="checkbox" disabled> Hiddify
@@ -435,106 +417,9 @@
</div> </div>
</div> </div>
<div id="xuiFields"
style="{% if not settings.sync.xui_sync %}display:none;{% endif %} border-top: 1px solid var(--border-color); padding-top: var(--space-md); margin-top: var(--space-md);">
<div class="form-hint" style="margin-bottom: var(--space-md);">
{{ _('xui_servers_manage_hint') }}
</div>
<div class="form-group">
<label
style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer; margin-bottom: var(--space-xs);">
<input type="checkbox" name="xui_sync_users" {% if settings.sync.xui_sync_users %}checked{% endif %}>
{{ _('enable_sync') }}
</label>
<div class="form-hint"
style="margin-left: var(--space-lg); line-height: 1.4; margin-bottom: var(--space-sm);">
{{ _('xui_sync_hint') }}
</div>
<div style="display: flex; gap: var(--space-sm); margin-left: var(--space-lg);">
<button type="button" class="btn btn-secondary btn-sm" onclick="syncXuiNow()" id="xuiSyncNowBtn">
<span id="xuiSyncNowBtnText">🔄 {{ _('sync_now_btn') }}</span>
<div class="spinner hidden" id="xuiSyncNowSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-danger btn-sm" onclick="deleteSyncXui()" id="xuiSyncDelBtn">
<span id="xuiSyncDelBtnText">🗑 {{ _('delete_sync_btn') }}</span>
<div class="spinner hidden" id="xuiSyncDelSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
<input type="checkbox" name="xui_create_conns" id="xuiSyncCreateConns" {% if
settings.sync.xui_create_conns %}checked{% endif %}>
{{ _('auto_create_conns') }}
</label>
</div>
<div id="xuiAutoConnFields"
style="{% if not settings.sync.xui_create_conns %}display:none;{% endif %} background: var(--bg-primary); padding: var(--space-md); border-radius: var(--radius-md); margin-top: var(--space-sm);">
<div class="form-group">
<label class="form-label">{{ _('sync_server_label') }}</label>
<select class="form-select" name="xui_server_id" onchange="updateProtocolsForXuiSync()">
{% for s in servers %}
<option value="{{ loop.index0 }}" {% if settings.sync.xui_server_id==loop.index0
%}selected{% endif %}>{{ s.name or s.host }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">{{ _('protocol_label') }}</label>
<select class="form-select" name="xui_protocol" id="xuiSyncProtocolSelect">
<option value="xray" {% if settings.sync.xui_protocol=='xray' %}selected{% endif %}>Xray</option>
<option value="awg" {% if settings.sync.xui_protocol=='awg' %}selected{% endif %}>AmneziaWG</option>
<option value="awg2" {% if settings.sync.xui_protocol=='awg2' %}selected{% endif %}>AmneziaWG 2.0</option>
</select>
</div>
</div>
<!-- keep hidden legacy fields so saveSettings still posts them -->
<input type="hidden" name="xui_url" value="{{ settings.sync.xui_url or '' }}">
<input type="hidden" name="xui_sub_url" value="{{ settings.sync.xui_sub_url or '' }}">
<input type="hidden" name="xui_api_token" value="{{ settings.sync.xui_api_token or '' }}">
<input type="hidden" name="xui_username" value="{{ settings.sync.xui_username or '' }}">
<input type="hidden" name="xui_password" value="{{ settings.sync.xui_password or '' }}">
<input type="hidden" name="xui_inbound_id" value="{{ settings.sync.xui_inbound_id or 0 }}">
</div>
</form> </form>
</div> </div>
<!-- BLOCK: 3x-ui servers (multi) -->
<div class="card" style="margin-top: var(--space-lg);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-lg);">
<h3 class="card-title" style="margin:0;">{{ _('xui_servers_title') }}</h3>
<button type="button" class="btn btn-primary btn-sm" onclick="openXuiServerModal()">+ {{ _('xui_server_add') }}</button>
</div>
<div class="form-hint" style="margin-bottom: var(--space-md);">{{ _('xui_servers_hint') }}</div>
<div id="xuiServersList">
{% if xui_servers %}
{% for s in xui_servers %}
<div class="client-item" style="margin-bottom:var(--space-sm);" data-xui-id="{{ s.id }}">
<div class="client-info" style="flex:1; min-width:0;">
<div class="client-avatar">🌀</div>
<div style="min-width:0;">
<div class="client-name">{{ s.name }}</div>
<div class="client-meta" style="word-break:break-all;">
<span>{{ s.url }}</span>
{% if s.sub_url %}<span>· sub: {{ s.sub_url }}</span>{% endif %}
</div>
</div>
</div>
<div class="client-actions">
<button class="btn btn-secondary btn-sm" type="button" onclick='editXuiServer({{ s | tojson }})'>{{ _('edit') }}</button>
<button class="btn btn-danger btn-sm" type="button" onclick="deleteXuiServer('{{ s.id }}')">{{ _('delete') }}</button>
</div>
</div>
{% endfor %}
{% else %}
<div style="text-align:center; padding:var(--space-lg); color:var(--text-muted);">{{ _('xui_servers_empty') }}</div>
{% endif %}
</div>
</div>
<!-- BLOCK: Simple Backup --> <!-- BLOCK: Simple Backup -->
<div class="card" style="margin-top: var(--space-lg);"> <div class="card" style="margin-top: var(--space-lg);">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">📤 {{ _('backup_title') }}</h3> <h3 class="card-title" style="margin-bottom: var(--space-lg);">📤 {{ _('backup_title') }}</h3>
@@ -614,64 +499,6 @@
</div> </div>
<!-- ===== 3x-ui Server Modal ===== -->
<div class="modal-backdrop" id="xuiServerModal">
<div class="modal" style="max-width:520px;">
<div class="modal-header">
<h2 class="modal-title" id="xuiServerModalTitle">{{ _('xui_server_add') }}</h2>
<button class="modal-close" onclick="closeModal('xuiServerModal')">×</button>
</div>
<input type="hidden" id="xuiServerEditId" value="">
<div class="form-group">
<label class="form-label">{{ _('xui_server_name_label') }}</label>
<input class="form-input" type="text" id="xuiServerName" placeholder="US / NL / Home">
<div class="form-hint">{{ _('xui_server_name_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('xui_url_label') }}</label>
<input class="form-input" type="url" id="xuiServerUrl" placeholder="https://panel.example.com:2053/path">
<div class="form-hint">{{ _('xui_url_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('xui_sub_url_label') }}</label>
<input class="form-input" type="url" id="xuiServerSubUrl" placeholder="https://sub.example.com:2096/sub">
<div class="form-hint">{{ _('xui_sub_url_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('xui_api_token_label') }}</label>
<input class="form-input" type="password" id="xuiServerToken" placeholder="{{ _('xui_api_token_placeholder') }}" autocomplete="off">
<div class="form-hint">{{ _('xui_api_token_hint') }}</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-sm);">
<div class="form-group">
<label class="form-label">{{ _('xui_username_label') }}</label>
<input class="form-input" type="text" id="xuiServerUser" autocomplete="off">
</div>
<div class="form-group">
<label class="form-label">{{ _('xui_password_label') }}</label>
<input class="form-input" type="password" id="xuiServerPass" autocomplete="off">
</div>
</div>
<div class="form-group">
<label style="display:flex; align-items:center; gap:var(--space-sm); cursor:pointer;">
<input type="checkbox" id="xuiServerEnabled" checked> {{ _('enabled') if False else 'Enabled' }}
</label>
</div>
<div class="form-group">
<label style="display:flex; align-items:center; gap:var(--space-sm); cursor:pointer;">
<input type="checkbox" id="xuiServerSyncUsers"> {{ _('enable_sync') }}
</label>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('xuiServerModal')">{{ _('cancel') }}</button>
<button class="btn btn-primary" onclick="saveXuiServer()" id="xuiServerSaveBtn">
<span id="xuiServerSaveText">{{ _('save') }}</span>
<div class="spinner hidden" id="xuiServerSaveSpinner" style="width:14px;height:14px;"></div>
</button>
</div>
</div>
</div>
<!-- ===== Create API Token Modal ===== --> <!-- ===== Create API Token Modal ===== -->
<div class="modal-backdrop" id="createTokenModal"> <div class="modal-backdrop" id="createTokenModal">
<div class="modal" style="max-width: 460px;"> <div class="modal" style="max-width: 460px;">
@@ -758,148 +585,15 @@
} }
} }
function updateProtocolsForXuiSync() {
const serverIdx = document.querySelector('[name="xui_server_id"]').value;
const protoSelect = document.getElementById('xuiSyncProtocolSelect');
protoSelect.innerHTML = '';
if (serverIdx === '' || !serversData[serverIdx]) return;
const protocols = serversData[serverIdx].protocols || {};
let count = 0;
for (const [key, info] of Object.entries(protocols)) {
if (info.installed) {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = key === 'awg' ? 'AmneziaWG' : (key === 'awg2' ? 'AmneziaWG 2.0' : (key === 'awg_legacy' ? 'AWG Legacy' : (key === 'xray' ? 'Xray' : key.toUpperCase())));
if (key === "{{ settings.sync.xui_protocol }}") opt.selected = true;
protoSelect.appendChild(opt);
count++;
}
}
if (count === 0) {
const opt = document.createElement('option');
opt.textContent = _('no_protocols');
opt.disabled = true;
protoSelect.appendChild(opt);
}
}
document.querySelector('[name="remnawave_sync"]').addEventListener('change', (e) => { document.querySelector('[name="remnawave_sync"]').addEventListener('change', (e) => {
document.getElementById('remnawaveFields').style.display = e.target.checked ? 'block' : 'none'; document.getElementById('remnawaveFields').style.display = e.target.checked ? 'block' : 'none';
}); });
document.querySelector('[name="xui_sync"]').addEventListener('change', (e) => {
document.getElementById('xuiFields').style.display = e.target.checked ? 'block' : 'none';
});
function openXuiServerModal(server) {
document.getElementById('xuiServerEditId').value = server?.id || '';
document.getElementById('xuiServerModalTitle').textContent = server?.id ? (_('edit') + ' — 3x-ui') : _('xui_server_add');
document.getElementById('xuiServerName').value = server?.name || '';
document.getElementById('xuiServerUrl').value = server?.url || '';
document.getElementById('xuiServerSubUrl').value = server?.sub_url || '';
document.getElementById('xuiServerToken').value = '';
document.getElementById('xuiServerUser').value = server?.username || '';
document.getElementById('xuiServerPass').value = '';
document.getElementById('xuiServerEnabled').checked = server ? !!server.enabled : true;
document.getElementById('xuiServerSyncUsers').checked = !!(server && server.sync_users);
openModal('xuiServerModal');
}
function editXuiServer(server) { openXuiServerModal(server); }
async function saveXuiServer() {
const id = document.getElementById('xuiServerEditId').value;
const body = {
name: document.getElementById('xuiServerName').value.trim(),
url: document.getElementById('xuiServerUrl').value.trim(),
sub_url: document.getElementById('xuiServerSubUrl').value.trim(),
api_token: document.getElementById('xuiServerToken').value,
username: document.getElementById('xuiServerUser').value.trim(),
password: document.getElementById('xuiServerPass').value,
enabled: document.getElementById('xuiServerEnabled').checked,
sync_users: document.getElementById('xuiServerSyncUsers').checked,
default_inbound_id: 0,
};
if (!body.url) { showToast(_('error') + ': URL', 'error'); return; }
const btn = document.getElementById('xuiServerSaveBtn');
const text = document.getElementById('xuiServerSaveText');
const spinner = document.getElementById('xuiServerSaveSpinner');
btn.disabled = true; text.textContent = _('saving') || 'Saving...'; spinner.classList.remove('hidden');
try {
if (id) await apiCall(`/api/settings/xui/servers/${encodeURIComponent(id)}`, 'PUT', body);
else await apiCall('/api/settings/xui/servers', 'POST', body);
showToast(_('saved') || 'OK', 'success');
closeModal('xuiServerModal');
setTimeout(() => location.reload(), 400);
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
} finally {
btn.disabled = false; text.textContent = _('save'); spinner.classList.add('hidden');
}
}
async function deleteXuiServer(id) {
if (!confirm(_('xui_server_delete_confirm') || 'Delete this 3x-ui server?')) return;
try {
await apiCall(`/api/settings/xui/servers/${encodeURIComponent(id)}`, 'DELETE');
showToast(_('deleted') || 'OK', 'success');
setTimeout(() => location.reload(), 400);
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
}
}
// Load VLESS inbounds from the selected 3x-ui panel
async function loadXuiInbounds() {
await loadGuestInbounds();
}
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="">${_('invite_inbound_loading')}</option>`;
try {
const panelSel = document.getElementById('guest_create_xui_panel');
const panelId = panelSel ? panelSel.value : '';
const q = panelId ? `?panel_id=${encodeURIComponent(panelId)}` : '';
const res = await fetch('/api/settings/xui/inbounds' + q);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to load inbounds');
const list = data.inbounds || [];
if (!list.length) {
select.innerHTML = `<option value="">${_('invite_inbound_empty')}</option>`;
return;
}
select.innerHTML = '';
list.forEach((ib, idx) => {
const opt = document.createElement('option');
opt.value = ib.id;
opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`;
if (preferred && String(ib.id) === String(preferred)) opt.selected = true;
else if (!preferred && idx === 0) opt.selected = true;
select.appendChild(opt);
});
} catch (err) {
console.warn('loadGuestInbounds:', err);
select.innerHTML = `<option value="">${_('error')}: ${err.message}</option>`;
}
}
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();
}); });
document.getElementById('xuiSyncCreateConns').addEventListener('change', (e) => {
document.getElementById('xuiAutoConnFields').style.display = e.target.checked ? 'block' : 'none';
if (e.target.checked) updateProtocolsForXuiSync();
});
document.getElementById('ssl_enabled').addEventListener('change', (e) => { document.getElementById('ssl_enabled').addEventListener('change', (e) => {
document.getElementById('sslFields').style.display = e.target.checked ? 'block' : 'none'; document.getElementById('sslFields').style.display = e.target.checked ? 'block' : 'none';
}); });
@@ -1149,78 +843,6 @@
} }
} }
async function syncXuiNow() {
const btn = document.getElementById('xuiSyncNowBtn');
const text = document.getElementById('xuiSyncNowBtnText');
const spinner = document.getElementById('xuiSyncNowSpinner');
const syncForm = document.getElementById('syncForm');
btn.disabled = true;
text.textContent = _('sync_running');
spinner.classList.remove('hidden');
try {
const payload = {
xui_sync: true,
xui_sync_users: true,
xui_url: syncForm.xui_url.value.trim(),
xui_username: syncForm.xui_username.value.trim(),
xui_password: syncForm.xui_password.value,
xui_api_token: syncForm.xui_api_token.value.trim(),
xui_create_conns: syncForm.xui_create_conns.checked,
xui_server_id: parseInt(syncForm.xui_server_id.value || '0'),
xui_protocol: syncForm.xui_protocol.value,
xui_inbound_id: parseInt(syncForm.xui_inbound_id.value || '0'),
xui_sub_url: (syncForm.xui_sub_url && syncForm.xui_sub_url.value || '').trim()
};
const res = await fetch('/api/settings/xui_sync_now', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (data.status === 'success') {
showToast(_('sync_success').replace('{}', data.count), 'success');
setTimeout(() => window.location.reload(), 1500);
} else {
throw new Error(data.message || data.error || 'Error occurred');
}
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
} finally {
btn.disabled = false;
text.textContent = `🔄 ${_('sync_now_btn')}`;
spinner.classList.add('hidden');
}
}
async function deleteSyncXui() {
if (!confirm(_('delete_sync_confirm'))) return;
const btn = document.getElementById('xuiSyncDelBtn');
const text = document.getElementById('xuiSyncDelBtnText');
const spinner = document.getElementById('xuiSyncDelSpinner');
btn.disabled = true;
text.textContent = _('deleting');
spinner.classList.remove('hidden');
try {
const res = await fetch('/api/settings/xui_sync_delete', { method: 'POST' });
const data = await res.json();
if (data.status === 'success') {
showToast(_('sync_deleted').replace('{}', data.count), 'success');
setTimeout(() => window.location.reload(), 1500);
}
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
} finally {
btn.disabled = false;
text.textContent = `🗑 ${_('delete_sync_btn')}`;
spinner.classList.add('hidden');
}
}
async function saveSettings() { async function saveSettings() {
const btn = document.getElementById('saveBtn'); const btn = document.getElementById('saveBtn');
const spinner = document.getElementById('saveSpinner'); const spinner = document.getElementById('saveSpinner');
@@ -1244,17 +866,6 @@
remnawave_create_conns: syncForm.remnawave_create_conns.checked, remnawave_create_conns: syncForm.remnawave_create_conns.checked,
remnawave_server_id: parseInt(syncForm.remnawave_server_id.value || '0'), remnawave_server_id: parseInt(syncForm.remnawave_server_id.value || '0'),
remnawave_protocol: syncForm.remnawave_protocol.value, remnawave_protocol: syncForm.remnawave_protocol.value,
xui_sync: syncForm.xui_sync.checked,
xui_url: syncForm.xui_url.value,
xui_username: syncForm.xui_username.value,
xui_password: syncForm.xui_password.value,
xui_api_token: syncForm.xui_api_token.value,
xui_sync_users: syncForm.xui_sync_users.checked,
xui_create_conns: syncForm.xui_create_conns.checked,
xui_server_id: parseInt(syncForm.xui_server_id.value || '0'),
xui_protocol: syncForm.xui_protocol.value,
xui_inbound_id: parseInt(syncForm.xui_inbound_id.value || '0'),
xui_sub_url: (syncForm.xui_sub_url && syncForm.xui_sub_url.value || '').trim()
}; };
@@ -1277,12 +888,10 @@
panel_port: parseInt(document.getElementById('panel_port').value || '5000') panel_port: parseInt(document.getElementById('panel_port').value || '5000')
}; };
let createProtocol = 'xui'; let createProtocol = 'awg';
let createServerId = 0; let createServerId = 0;
const guestProtoRaw = document.getElementById('guest_create_protocol').value; const guestProtoRaw = document.getElementById('guest_create_protocol').value;
if (guestProtoRaw === 'xui') { if (guestProtoRaw.includes('|')) {
createProtocol = 'xui';
} else if (guestProtoRaw.includes('|')) {
const parts = guestProtoRaw.split('|'); const parts = guestProtoRaw.split('|');
createProtocol = parts[0]; createProtocol = parts[0];
createServerId = parseInt(parts[1] || '0'); createServerId = parseInt(parts[1] || '0');
@@ -1297,8 +906,6 @@
allow_create: document.getElementById('guest_allow_create').checked, allow_create: document.getElementById('guest_allow_create').checked,
create_protocol: createProtocol, create_protocol: createProtocol,
create_server_id: createServerId, create_server_id: createServerId,
create_inbound_id: parseInt(document.getElementById('guest_create_inbound_id').value || '0'),
create_xui_panel_id: document.getElementById('guest_create_xui_panel')?.value || '',
}; };
try { try {
@@ -1319,15 +926,7 @@
} }
function updateGuestCreateFields() { function updateGuestCreateFields() {
const v = document.getElementById('guest_create_protocol')?.value; // No-op: only server-backed protocols are selectable now.
const group = document.getElementById('guestInboundGroup');
if (group) group.style.display = (v === 'xui') ? 'block' : 'none';
if (v === 'xui') loadGuestInbounds();
}
// Prefill guest inbound list when page opens on 3x-ui
if (document.getElementById('guest_create_protocol')?.value === 'xui') {
loadGuestInbounds();
} }
function copyGuestLink() { function copyGuestLink() {
+1 -74
View File
@@ -291,20 +291,6 @@
<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_server_select_label') }}</label>
<select class="form-select" id="ucXuiPanel" onchange="loadXuiInbounds()">
{% for s in xui_servers %}
<option value="{{ s.id }}" {% if s.id == xui_default_panel_id %}selected{% endif %}>{{ s.name }}</option>
{% endfor %}
</select>
<label class="form-label" style="margin-top:var(--space-sm);">{{ _('xui_inbound_label') }}</label>
<select class="form-select" id="ucXuiInbound">
<option value="">{{ _('invite_inbound_loading') }}</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') }}">
@@ -411,9 +397,6 @@
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 }};
const xuiServers = {{ xui_servers | default([]) | tojson }};
function populateTimeSelect(selectId) { function populateTimeSelect(selectId) {
const select = document.getElementById(selectId); const select = document.getElementById(selectId);
@@ -620,14 +603,6 @@
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 {
@@ -637,48 +612,6 @@
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="">${_('invite_inbound_loading')}</option>`;
try {
const panelId = document.getElementById('ucXuiPanel')?.value || '';
const q = panelId ? `?panel_id=${encodeURIComponent(panelId)}` : '';
const data = await apiCall('/api/settings/xui/inbounds' + q);
const list = data.inbounds || [];
if (!list.length) {
select.innerHTML = `<option value="">${_('invite_inbound_empty')}</option>`;
return;
}
select.innerHTML = '';
list.forEach((ib, idx) => {
const opt = document.createElement('option');
opt.value = ib.id;
opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`;
if (xuiDefaultInboundId && String(ib.id) === String(xuiDefaultInboundId)) opt.selected = true;
else if (!xuiDefaultInboundId && idx === 0) opt.selected = true;
select.appendChild(opt);
});
} catch (err) {
select.innerHTML = `<option value="">${_('error')}: ${err.message}</option>`;
}
}
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
@@ -711,7 +644,6 @@
fetchExistingClients(); fetchExistingClients();
} }
updateTelemtOptions('ucProtocol', 'ucTelemtOptions'); updateTelemtOptions('ucProtocol', 'ucTelemtOptions');
updateXuiInboundVisibility();
}); });
async function addUser(e) { async function addUser(e) {
@@ -878,11 +810,6 @@
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 = parseInt(document.getElementById('ucXuiInbound').value || '0');
if (!inbound) throw new Error(_('invite_inbound_required'));
body.xui_inbound_id = inbound;
body.xui_panel_id = document.getElementById('ucXuiPanel')?.value || '';
} 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;
@@ -938,7 +865,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 === 'xui' ? '3x-ui VLESS' : 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.toUpperCase())))}</span>
</div> </div>
</div> </div>
</div> </div>
+1 -31
View File
@@ -186,7 +186,7 @@
"guest_password_keep": "Leave blank to keep current password", "guest_password_keep": "Leave blank to keep current password",
"guest_clear_password": "Remove password protection", "guest_clear_password": "Remove password protection",
"guest_allow_create": "Allow guests to create a new config", "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_allow_create_hint": "Creates a config for the guest user using the selected server protocol.",
"guest_access_btn": "Continue as guest", "guest_access_btn": "Continue as guest",
"guest_access_login_hint": "No account required", "guest_access_login_hint": "No account required",
"guest_title": "Guest access", "guest_title": "Guest access",
@@ -220,13 +220,6 @@
"invite_duration_short": "Lifetime", "invite_duration_short": "Lifetime",
"invite_duration_starts_hint": "After you get a config it will work for {} day(s)", "invite_duration_starts_hint": "After you get a config it will work for {} day(s)",
"invite_config_expires_at": "Config valid until: {}", "invite_config_expires_at": "Config valid until: {}",
"invite_inbound_hint": "Inbounds are loaded from the selected 3x-ui panel. Only 3x-ui returns the config/share link.",
"invite_inbound_loading": "Loading inbounds from 3x-ui…",
"invite_inbound_empty": "No VLESS inbounds on this 3x-ui panel",
"invite_inbound_need_xui": "Configure 3x-ui in Settings first",
"invite_inbound_required": "Select a VLESS inbound from 3x-ui",
"invite_sub_url_missing_title": "Subscription URL is not set",
"invite_sub_url_missing_hint": "In Settings → 3x-ui set Subscription URL base so invite links issue /sub/… configs.",
"invite_sub_tab": "Subscription", "invite_sub_tab": "Subscription",
"days_short": "d", "days_short": "d",
"invite_clear_expires": "Remove expiration", "invite_clear_expires": "Remove expiration",
@@ -289,29 +282,6 @@
"api_key_label": "API Key", "api_key_label": "API Key",
"enable_sync": "Enable synchronization", "enable_sync": "Enable synchronization",
"sync_hint": "Users will be created, disabled, and deleted automatically based on Remnawave data", "sync_hint": "Users will be created, disabled, and deleted automatically based on Remnawave data",
"xui_url_label": "3x-ui URL",
"xui_url_hint": "Include scheme, port and webBasePath if configured (example: https://host:2053/secret)",
"xui_sub_url_label": "Subscription URL base",
"xui_sub_url_hint": "Public /sub base without trailing slash (example: https://host:2096/sub). Invite configs are issued as subscription links.",
"xui_api_token_label": "API Token (preferred)",
"xui_api_token_placeholder": "Settings → Security → API Token",
"xui_api_token_hint": "If set, username/password login is skipped",
"xui_username_label": "3x-ui Username",
"xui_password_label": "3x-ui Password",
"xui_sync_hint": "Clients from 3x-ui inbounds will be created, disabled, and deleted as panel users (matched by email)",
"xui_inbound_label": "VLESS inbound",
"xui_inbound_auto": "Auto (first VLESS inbound)",
"xui_inbound_hint": "Inbounds are loaded from 3x-ui. This site only creates the client and takes the ready share link — no local config generation.",
"xui_servers_title": "3x-ui servers",
"xui_servers_hint": "Add 3x-ui panels. When creating, pick a server and an inbound from the 3x-ui list — the panel returns the share link.",
"xui_servers_empty": "No 3x-ui servers yet. Click Add server.",
"xui_servers_manage_hint": "Panel URLs and subscription bases are managed in the “3x-ui servers” section below.",
"xui_server_add": "Add server",
"xui_server_name_label": "Name / description",
"xui_server_name_hint": "Shown in invites and user forms (e.g. US, NL, Home).",
"xui_server_select_label": "3x-ui server",
"xui_server_select_hint": "Pick a 3x-ui panel, then an inbound from the list that panel returns via API.",
"xui_server_delete_confirm": "Delete this 3x-ui server from the panel?",
"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",
+1 -31
View File
@@ -184,7 +184,7 @@
"guest_password_keep": "برای حفظ رمز فعلی خالی بگذارید", "guest_password_keep": "برای حفظ رمز فعلی خالی بگذارید",
"guest_clear_password": "حذف محافظت با رمز", "guest_clear_password": "حذف محافظت با رمز",
"guest_allow_create": "اجازه ساخت کانفیگ جدید به مهمان", "guest_allow_create": "اجازه ساخت کانفیگ جدید به مهمان",
"guest_allow_create_hint": "برای کاربر مهمان کانفیگ می‌سازد (3x-ui VLESS یا پروتکل سرور).", "guest_allow_create_hint": "برای کاربر مهمان با استفاده از پروتکل سرور انتخاب‌شده کانفیگ می‌سازد.",
"guest_access_btn": "ادامه به‌عنوان مهمان", "guest_access_btn": "ادامه به‌عنوان مهمان",
"guest_access_login_hint": "بدون حساب کاربری", "guest_access_login_hint": "بدون حساب کاربری",
"guest_title": "دسترسی مهمان", "guest_title": "دسترسی مهمان",
@@ -218,13 +218,6 @@
"invite_duration_short": "مدت", "invite_duration_short": "مدت",
"invite_duration_starts_hint": "پس از دریافت، کانفیگ {} روز معتبر است", "invite_duration_starts_hint": "پس از دریافت، کانفیگ {} روز معتبر است",
"invite_config_expires_at": "اعتبار تا: {}", "invite_config_expires_at": "اعتبار تا: {}",
"invite_inbound_hint": "یک‌بار inbound مورد نیاز VLESS را انتخاب کنید",
"invite_inbound_loading": "در حال بارگذاری…",
"invite_inbound_empty": "inbound VLESS یافت نشد",
"invite_inbound_need_xui": "ابتدا 3x-ui را در تنظیمات پیکربندی کنید",
"invite_inbound_required": "یک inbound VLESS انتخاب کنید",
"invite_sub_url_missing_title": "آدرس اشتراک تنظیم نشده",
"invite_sub_url_missing_hint": "در تنظیمات → 3x-ui پایه /sub را وارد کنید.",
"invite_sub_tab": "اشتراک", "invite_sub_tab": "اشتراک",
"days_short": "روز", "days_short": "روز",
"invite_clear_expires": "حذف تاریخ انقضا", "invite_clear_expires": "حذف تاریخ انقضا",
@@ -287,29 +280,6 @@
"api_key_label": "کلید API", "api_key_label": "کلید API",
"enable_sync": "فعال‌سازی همگام‌سازی", "enable_sync": "فعال‌سازی همگام‌سازی",
"sync_hint": "کاربران طبق داده‌های Remnawave به‌طور خودکار مدیریت می‌شوند", "sync_hint": "کاربران طبق داده‌های Remnawave به‌طور خودکار مدیریت می‌شوند",
"xui_url_label": "آدرس 3x-ui",
"xui_url_hint": "شامل پروتکل، پورت و webBasePath در صورت وجود",
"xui_sub_url_label": "آدرس پایه اشتراک",
"xui_sub_url_hint": "پایه عمومی /sub بدون اسلش پایانی (مثال: https://host:2096/sub).",
"xui_api_token_label": "توکن API (ترجیحی)",
"xui_api_token_placeholder": "Settings → Security → API Token",
"xui_api_token_hint": "در صورت تنظیم، ورود با نام کاربری/رمز رد می‌شود",
"xui_username_label": "نام کاربری 3x-ui",
"xui_password_label": "رمز عبور 3x-ui",
"xui_sync_hint": "کلاینت‌های 3x-ui بر اساس email به کاربران پنل همگام می‌شوند",
"xui_inbound_label": "ورودی VLESS",
"xui_inbound_auto": "خودکار (اولین ورودی VLESS)",
"xui_inbound_hint": "برای ساخت کانفیگ 3x-ui VLESS استفاده می‌شود. ابتدا یک ورودی VLESS در 3x-ui بسازید.",
"xui_servers_title": "سرورهای 3x-ui",
"xui_servers_hint": "چند پنل 3x-ui اضافه کنید. هنگام ساخت، سرور را با نام انتخاب کنید تا URL اشتراک همان سرور داده شود.",
"xui_servers_empty": "هنوز سرور 3x-ui نیست. افزودن را بزنید.",
"xui_servers_manage_hint": "آدرس پنل و پایه اشتراک در بخش «سرورهای 3x-ui» مدیریت می‌شود.",
"xui_server_add": "افزودن سرور",
"xui_server_name_label": "نام / توضیح",
"xui_server_name_hint": "در دعوت‌ها نمایش داده می‌شود (مثل US، NL).",
"xui_server_select_label": "سرور 3x-ui",
"xui_server_select_hint": "پنل 3x-ui که URL اشتراک می‌دهد را انتخاب کنید.",
"xui_server_delete_confirm": "این سرور 3x-ui حذف شود؟",
"sync_now_btn": "🔄 همگام‌سازی اکنون", "sync_now_btn": "🔄 همگام‌سازی اکنون",
"delete_sync_btn": "🗑 حذف همگام‌سازی", "delete_sync_btn": "🗑 حذف همگام‌سازی",
"auto_create_conns": "ایجاد خودکار اتصال‌ها", "auto_create_conns": "ایجاد خودکار اتصال‌ها",
+1 -31
View File
@@ -184,7 +184,7 @@
"guest_password_keep": "Laisser vide pour conserver", "guest_password_keep": "Laisser vide pour conserver",
"guest_clear_password": "Retirer le mot de passe", "guest_clear_password": "Retirer le mot de passe",
"guest_allow_create": "Autoriser la création d'une config", "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_allow_create_hint": "Crée une config pour l'utilisateur invité en utilisant le protocole du serveur sélectionné.",
"guest_access_btn": "Continuer en invité", "guest_access_btn": "Continuer en invité",
"guest_access_login_hint": "Sans compte", "guest_access_login_hint": "Sans compte",
"guest_title": "Accès invité", "guest_title": "Accès invité",
@@ -218,13 +218,6 @@
"invite_duration_short": "Durée", "invite_duration_short": "Durée",
"invite_duration_starts_hint": "Après obtention, valable {} jour(s)", "invite_duration_starts_hint": "Après obtention, valable {} jour(s)",
"invite_config_expires_at": "Valable jusqu'au : {}", "invite_config_expires_at": "Valable jusqu'au : {}",
"invite_inbound_hint": "Choisissez l'inbound VLESS une fois",
"invite_inbound_loading": "Chargement…",
"invite_inbound_empty": "Aucun inbound VLESS",
"invite_inbound_need_xui": "Configurez 3x-ui d'abord",
"invite_inbound_required": "Sélectionnez un inbound VLESS",
"invite_sub_url_missing_title": "URL d'abonnement manquante",
"invite_sub_url_missing_hint": "Dans Paramètres → 3x-ui, définissez l'URL de base /sub.",
"invite_sub_tab": "Abonnement", "invite_sub_tab": "Abonnement",
"days_short": "j", "days_short": "j",
"invite_clear_expires": "Retirer l'expiration", "invite_clear_expires": "Retirer l'expiration",
@@ -287,29 +280,6 @@
"api_key_label": "Clé API", "api_key_label": "Clé API",
"enable_sync": "Activer synchronisation", "enable_sync": "Activer synchronisation",
"sync_hint": "Synchro automatique avec Remnawave", "sync_hint": "Synchro automatique avec Remnawave",
"xui_url_label": "URL 3x-ui",
"xui_url_hint": "Inclure le schéma, le port et webBasePath si configuré",
"xui_sub_url_label": "URL de base d'abonnement",
"xui_sub_url_hint": "Base /sub publique sans slash final (ex: https://host:2096/sub).",
"xui_api_token_label": "Jeton API (préféré)",
"xui_api_token_placeholder": "Settings → Security → API Token",
"xui_api_token_hint": "Si défini, le login/mot de passe est ignoré",
"xui_username_label": "Identifiant 3x-ui",
"xui_password_label": "Mot de passe 3x-ui",
"xui_sync_hint": "Les clients 3x-ui deviennent des utilisateurs du panneau (liés par email)",
"xui_inbound_label": "Inbound VLESS",
"xui_inbound_auto": "Auto (premier inbound VLESS)",
"xui_inbound_hint": "Utilisé pour créer des configs 3x-ui VLESS. Créez d'abord un inbound VLESS dans 3x-ui.",
"xui_servers_title": "Serveurs 3x-ui",
"xui_servers_hint": "Ajoutez plusieurs panneaux 3x-ui. À la création, choisissez le serveur par nom — son URL d'abonnement est utilisée.",
"xui_servers_empty": "Aucun serveur 3x-ui. Cliquez sur Ajouter.",
"xui_servers_manage_hint": "Les URL et bases d'abonnement se gèrent dans la section « Serveurs 3x-ui » ci-dessous.",
"xui_server_add": "Ajouter un serveur",
"xui_server_name_label": "Nom / description",
"xui_server_name_hint": "Affiché dans les invitations (ex: US, NL).",
"xui_server_select_label": "Serveur 3x-ui",
"xui_server_select_hint": "Choisissez le panneau 3x-ui qui émettra l'URL d'abonnement.",
"xui_server_delete_confirm": "Supprimer ce serveur 3x-ui ?",
"sync_now_btn": "🔄 Sync maintenant", "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",
+1 -31
View File
@@ -186,7 +186,7 @@
"guest_password_keep": "Оставьте пустым, чтобы сохранить текущий", "guest_password_keep": "Оставьте пустым, чтобы сохранить текущий",
"guest_clear_password": "Убрать защиту паролем", "guest_clear_password": "Убрать защиту паролем",
"guest_allow_create": "Разрешить гостям создавать новый конфиг", "guest_allow_create": "Разрешить гостям создавать новый конфиг",
"guest_allow_create_hint": "Создаёт конфиг у гостевого пользователя (3x-ui VLESS или протокол выбранного сервера).", "guest_allow_create_hint": "Создаёт конфиг у гостевого пользователя с использованием протокола выбранного сервера.",
"guest_access_btn": "Войти как гость", "guest_access_btn": "Войти как гость",
"guest_access_login_hint": "Регистрация не нужна", "guest_access_login_hint": "Регистрация не нужна",
"guest_title": "Гостевой доступ", "guest_title": "Гостевой доступ",
@@ -220,13 +220,6 @@
"invite_duration_short": "Срок", "invite_duration_short": "Срок",
"invite_duration_starts_hint": "После получения конфиг будет действовать {} дн.", "invite_duration_starts_hint": "После получения конфиг будет действовать {} дн.",
"invite_config_expires_at": "Конфиг действует до: {}", "invite_config_expires_at": "Конфиг действует до: {}",
"invite_inbound_hint": "Список inbound загружается из выбранной панели 3x-ui. Конфиг и ссылку отдаёт только 3x-ui.",
"invite_inbound_loading": "Загрузка inbound из 3x-ui…",
"invite_inbound_empty": "VLESS inbound не найдены на этой панели 3x-ui",
"invite_inbound_need_xui": "Сначала настройте 3x-ui в Настройках",
"invite_inbound_required": "Выберите VLESS inbound из 3x-ui",
"invite_sub_url_missing_title": "Не задан URL подписки",
"invite_sub_url_missing_hint": "В Настройки → 3x-ui укажите «Базовый URL подписки», чтобы ссылки выдавали /sub/… конфиги.",
"invite_sub_tab": "Подписка", "invite_sub_tab": "Подписка",
"days_short": "дн", "days_short": "дн",
"invite_clear_expires": "Убрать срок действия", "invite_clear_expires": "Убрать срок действия",
@@ -289,29 +282,6 @@
"api_key_label": "API Key", "api_key_label": "API Key",
"enable_sync": "Включить синхронизацию", "enable_sync": "Включить синхронизацию",
"sync_hint": "Пользователи будут создаваться, отключаться и удаляться автоматически на основе данных из Remnawave", "sync_hint": "Пользователи будут создаваться, отключаться и удаляться автоматически на основе данных из Remnawave",
"xui_url_label": "URL 3x-ui",
"xui_url_hint": "Укажите схему, порт и webBasePath при наличии (пример: https://host:2053/secret)",
"xui_sub_url_label": "Базовый URL подписки",
"xui_sub_url_hint": "Публичный /sub без слэша в конце (пример: https://host:2096/sub). Конфиги по ссылкам выдаются как subscription URL.",
"xui_api_token_label": "API Token (предпочтительно)",
"xui_api_token_placeholder": "Settings → Security → API Token",
"xui_api_token_hint": "Если задан, вход по логину/паролю не используется",
"xui_username_label": "Логин 3x-ui",
"xui_password_label": "Пароль 3x-ui",
"xui_sync_hint": "Клиенты из inbounds 3x-ui будут создаваться, отключаться и удаляться как пользователи панели (связь по email)",
"xui_inbound_label": "VLESS inbound",
"xui_inbound_auto": "Авто (первый VLESS inbound)",
"xui_inbound_hint": "Inbound подгружается из 3x-ui. Сайт только создаёт клиента и забирает готовую ссылку — без своей генерации конфига.",
"xui_servers_title": "Серверы 3x-ui",
"xui_servers_hint": "Добавьте панели 3x-ui. При создании выберите сервер и inbound из списка 3x-ui — ссылку вернёт панель.",
"xui_servers_empty": "Серверов 3x-ui пока нет. Нажмите «Добавить сервер».",
"xui_servers_manage_hint": "URL панелей и базовые URL подписок настраиваются в разделе «Серверы 3x-ui» ниже.",
"xui_server_add": "Добавить сервер",
"xui_server_name_label": "Название / описание",
"xui_server_name_hint": "Так сервер будет отображаться в инвайтах и у пользователей (например: US, NL, Home).",
"xui_server_select_label": "Сервер 3x-ui",
"xui_server_select_hint": "Выберите панель 3x-ui, затем inbound из списка, который она отдаёт по API.",
"xui_server_delete_confirm": "Удалить этот сервер 3x-ui из панели?",
"sync_now_btn": "🔄 Синхронизировать сейчас", "sync_now_btn": "🔄 Синхронизировать сейчас",
"delete_sync_btn": "🗑 Удалить синхронизацию", "delete_sync_btn": "🗑 Удалить синхронизацию",
"auto_create_conns": "Создавать подключения автоматически", "auto_create_conns": "Создавать подключения автоматически",
+1 -31
View File
@@ -184,7 +184,7 @@
"guest_password_keep": "留空以保留当前密码", "guest_password_keep": "留空以保留当前密码",
"guest_clear_password": "取消密码保护", "guest_clear_password": "取消密码保护",
"guest_allow_create": "允许访客创建新配置", "guest_allow_create": "允许访客创建新配置",
"guest_allow_create_hint": "为访客用户创建配置3x-ui VLESS 或所选服务器协议)。", "guest_allow_create_hint": "使用所选服务器协议为访客用户创建配置。",
"guest_access_btn": "以访客继续", "guest_access_btn": "以访客继续",
"guest_access_login_hint": "无需账号", "guest_access_login_hint": "无需账号",
"guest_title": "访客访问", "guest_title": "访客访问",
@@ -218,13 +218,6 @@
"invite_duration_short": "有效期", "invite_duration_short": "有效期",
"invite_duration_starts_hint": "获取后配置将有效 {} 天", "invite_duration_starts_hint": "获取后配置将有效 {} 天",
"invite_config_expires_at": "配置有效至:{}", "invite_config_expires_at": "配置有效至:{}",
"invite_inbound_hint": "一次性选择所需的 VLESS 入站",
"invite_inbound_loading": "加载入站中…",
"invite_inbound_empty": "未找到 VLESS 入站",
"invite_inbound_need_xui": "请先在设置中配置 3x-ui",
"invite_inbound_required": "请选择 VLESS 入站",
"invite_sub_url_missing_title": "未设置订阅 URL",
"invite_sub_url_missing_hint": "在 设置 → 3x-ui 中填写订阅基础 URL,以便发放 /sub/… 配置。",
"invite_sub_tab": "订阅", "invite_sub_tab": "订阅",
"days_short": "天", "days_short": "天",
"invite_clear_expires": "清除过期时间", "invite_clear_expires": "清除过期时间",
@@ -287,29 +280,6 @@
"api_key_label": "API 密钥", "api_key_label": "API 密钥",
"enable_sync": "开启同步", "enable_sync": "开启同步",
"sync_hint": "将根据 Remnawave 数据自动创建、禁用和删除用户", "sync_hint": "将根据 Remnawave 数据自动创建、禁用和删除用户",
"xui_url_label": "3x-ui 地址",
"xui_url_hint": "包含协议、端口和 webBasePath(如有)",
"xui_sub_url_label": "订阅基础 URL",
"xui_sub_url_hint": "公共 /sub 基础地址,末尾不要斜杠(如 https://host:2096/sub)。",
"xui_api_token_label": "API Token(推荐)",
"xui_api_token_placeholder": "Settings → Security → API Token",
"xui_api_token_hint": "若已设置,将跳过用户名/密码登录",
"xui_username_label": "3x-ui 用户名",
"xui_password_label": "3x-ui 密码",
"xui_sync_hint": "3x-ui 客户端将按 email 同步为面板用户",
"xui_inbound_label": "VLESS 入站",
"xui_inbound_auto": "自动(第一个 VLESS 入站)",
"xui_inbound_hint": "创建 3x-ui VLESS 配置时使用。请先在 3x-ui 中创建 VLESS 入站。",
"xui_servers_title": "3x-ui 服务器",
"xui_servers_hint": "可添加多个 3x-ui 面板。创建配置时按名称选择服务器,并使用其订阅基础 URL。",
"xui_servers_empty": "暂无 3x-ui 服务器。请点击添加。",
"xui_servers_manage_hint": "面板与订阅地址在下方「3x-ui 服务器」中管理。",
"xui_server_add": "添加服务器",
"xui_server_name_label": "名称 / 描述",
"xui_server_name_hint": "显示在邀请与用户表单中(如 US、NL)。",
"xui_server_select_label": "3x-ui 服务器",
"xui_server_select_hint": "选择签发订阅 URL 的 3x-ui 面板。",
"xui_server_delete_confirm": "从面板删除此 3x-ui 服务器?",
"sync_now_btn": "🔄 立即同步", "sync_now_btn": "🔄 立即同步",
"delete_sync_btn": "🗑 删除同步数据", "delete_sync_btn": "🗑 删除同步数据",
"auto_create_conns": "自动创建连接", "auto_create_conns": "自动创建连接",