Add 3x-ui user sync via panel API (login or Bearer token).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-26 00:14:27 +03:00
co-authored by Cursor
parent 865260cc9d
commit 0acd27d840
10 changed files with 551 additions and 8 deletions
+296 -1
View File
@@ -1297,6 +1297,7 @@ async def sync_users_with_remnawave(data: dict):
'enabled': is_active,
'created_at': datetime.now().isoformat(),
'remnawave_uuid': rw_u['uuid'],
'xui_email': None,
'share_enabled': False,
'share_token': secrets.token_urlsafe(16),
'share_password_hash': None,
@@ -1329,6 +1330,255 @@ async def sync_users_with_remnawave(data: dict):
return 0, f"Error: {str(e)}"
def _xui_sanitize_username(email: str) -> str:
raw = (email or '').strip()
if not raw:
return ''
# Keep readable usernames; strip characters that break our forms
cleaned = re.sub(r'[^\w.\-@]+', '_', raw, flags=re.UNICODE)
return cleaned[:64] or raw[:64]
def _xui_parse_clients_from_inbounds(inbounds) -> list:
"""Extract unique clients from classic 3x-ui inbounds list payload."""
clients_by_email = {}
if not isinstance(inbounds, list):
return []
for inbound in inbounds:
settings = inbound.get('settings') if isinstance(inbound, dict) else None
if isinstance(settings, str):
try:
settings = json.loads(settings)
except Exception:
settings = {}
if not isinstance(settings, dict):
continue
for client in settings.get('clients') or []:
if not isinstance(client, dict):
continue
email = (client.get('email') or '').strip()
if not email:
continue
# Prefer enabled=true if the same email appears in multiple inbounds
prev = clients_by_email.get(email)
if prev and prev.get('enable') and not client.get('enable', True):
continue
clients_by_email[email] = client
return list(clients_by_email.values())
async def _xui_fetch_clients(client: httpx.AsyncClient, base_url: str, headers: dict) -> list:
"""Fetch clients from 3x-ui, supporting new and legacy API shapes."""
base = base_url.rstrip('/')
# Newer panels: dedicated clients list
for path in ('/panel/api/clients/list', '/panel/api/inbounds/list'):
try:
resp = await client.get(base + path, headers=headers)
except Exception:
continue
if resp.status_code != 200:
continue
try:
payload = resp.json()
except Exception:
continue
if not payload.get('success', True) and payload.get('success') is False:
continue
obj = payload.get('obj', payload.get('data'))
if path.endswith('/clients/list') and isinstance(obj, list):
# Deduplicate by email
by_email = {}
for c in obj:
if not isinstance(c, dict):
continue
email = (c.get('email') or '').strip()
if email:
by_email[email] = c
if by_email:
return list(by_email.values())
if path.endswith('/inbounds/list'):
clients = _xui_parse_clients_from_inbounds(obj if isinstance(obj, list) else [])
if clients:
return clients
# Legacy: POST /panel/api/inbounds/list (and /panel/inbound/list)
for path in ('/panel/api/inbounds/list', '/panel/inbound/list'):
try:
resp = await client.post(base + path, headers=headers)
except Exception:
continue
if resp.status_code != 200:
continue
try:
payload = resp.json()
except Exception:
continue
obj = payload.get('obj', payload.get('data'))
clients = _xui_parse_clients_from_inbounds(obj if isinstance(obj, list) else [])
if clients:
return clients
return []
async def sync_users_with_xui(data: dict):
"""Import / update panel users from an existing 3x-ui instance."""
settings = data.get('settings', {}).get('sync', {})
if not settings.get('xui_sync_users'):
return 0, "3x-ui synchronization is disabled in settings"
url = (settings.get('xui_url') or '').strip()
if not url:
return 0, "3x-ui URL not configured"
api_token = (settings.get('xui_api_token') or '').strip()
username = (settings.get('xui_username') or '').strip()
password = settings.get('xui_password') or ''
try:
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
headers = {'Accept': 'application/json'}
if api_token:
headers['Authorization'] = f'Bearer {api_token}'
else:
if not username or not password:
return 0, "Provide 3x-ui API token or username/password"
login_resp = await client.post(
url.rstrip('/') + '/login',
json={'username': username, 'password': password},
headers=headers,
)
if login_resp.status_code != 200:
return 0, f"3x-ui login failed: {login_resp.status_code} {login_resp.text[:200]}"
try:
login_body = login_resp.json()
except Exception:
login_body = {}
if login_body.get('success') is False:
return 0, f"3x-ui login failed: {login_body.get('msg', 'unknown error')}"
xui_clients = await _xui_fetch_clients(client, url, headers)
if not xui_clients:
return 0, "No clients found in 3x-ui (check URL/credentials/API path)"
xui_emails = {(c.get('email') or '').strip() for c in xui_clients if (c.get('email') or '').strip()}
# 1. Delete local users that were synced from 3x-ui but no longer exist there
to_delete_ids = []
for u in data['users']:
email = (u.get('xui_email') or '').strip()
if email and email not in xui_emails:
to_delete_ids.append(u['id'])
if to_delete_ids:
logger.info(f"Removing {len(to_delete_ids)} users deleted in 3x-ui")
await perform_mass_operations(delete_uids=to_delete_ids)
synced_count = 0
to_toggle = []
to_create_conns = []
for xc in xui_clients:
email = (xc.get('email') or '').strip()
if not email:
continue
data = load_data()
local_u = next((u for u in data['users'] if (u.get('xui_email') or '') == email), None)
uname = _xui_sanitize_username(email)
if not local_u and uname:
local_u = next((u for u in data['users'] if u['username'] == uname), None)
is_active = bool(xc.get('enable', True))
tg_id = xc.get('tgId') or xc.get('telegramId') or None
if tg_id is not None:
tg_id = str(tg_id) if str(tg_id).strip() else None
# totalGB in 3x-ui is usually bytes despite the name
traffic_limit = int(xc.get('totalGB') or 0)
expiry_ms = int(xc.get('expiryTime') or 0)
expiration_date = None
if expiry_ms > 0:
try:
expiration_date = datetime.fromtimestamp(expiry_ms / 1000.0).isoformat()
except Exception:
expiration_date = None
if local_u:
local_u['username'] = uname or local_u['username']
local_u['telegramId'] = tg_id
local_u['email'] = email if '@' in email else local_u.get('email')
local_u['description'] = xc.get('comment') or local_u.get('description')
local_u['xui_email'] = email
local_u['traffic_limit'] = traffic_limit
local_u['expiration_date'] = expiration_date
if local_u.get('enabled', True) != is_active:
to_toggle.append((local_u['id'], is_active))
async with DATA_LOCK:
current = load_data()
idx = next((i for i, u in enumerate(current['users']) if u['id'] == local_u['id']), -1)
if idx != -1:
current['users'][idx] = local_u
save_data(current)
synced_count += 1
else:
new_id = str(uuid.uuid4())
# Avoid username collisions
final_name = uname or f"xui_{new_id[:8]}"
existing_names = {u['username'] for u in data['users']}
if final_name in existing_names:
final_name = f"{final_name}_{new_id[:6]}"
new_user = {
'id': new_id,
'username': final_name,
'password_hash': '',
'role': 'user',
'telegramId': tg_id,
'email': email if '@' in email else None,
'description': xc.get('comment'),
'enabled': is_active,
'created_at': datetime.now().isoformat(),
'remnawave_uuid': None,
'xui_email': email,
'traffic_limit': traffic_limit,
'traffic_used': 0,
'traffic_total': 0,
'traffic_reset_strategy': 'never',
'last_reset_at': datetime.now().isoformat(),
'expiration_date': expiration_date,
'share_enabled': False,
'share_token': secrets.token_urlsafe(16),
'share_password_hash': None,
}
async with DATA_LOCK:
current = load_data()
current['users'].append(new_user)
save_data(current)
if settings.get('xui_create_conns'):
sid = settings.get('xui_server_id')
if sid is not None:
to_create_conns.append({
'user_id': new_id,
'server_id': sid,
'protocol': settings.get('xui_protocol', 'xray'),
'name': f"{final_name}_vpn",
})
synced_count += 1
if to_toggle or to_create_conns:
logger.info(
f"Executing mass ops for 3x-ui sync: toggle={len(to_toggle)}, create={len(to_create_conns)}"
)
await perform_mass_operations(toggle_uids=to_toggle, create_conns=to_create_conns)
return synced_count, "Successfully synchronized with 3x-ui"
except Exception as e:
logger.exception("3x-ui synchronization error")
return 0, f"Error: {str(e)}"
def get_current_user(request: Request):
user_id = request.session.get('user_id')
if not user_id:
@@ -1513,6 +1763,15 @@ class SyncSettings(BaseModel):
remnawave_create_conns: bool = False
remnawave_server_id: int = 0
remnawave_protocol: str = 'awg'
xui_sync: bool = False
xui_url: str = ''
xui_username: str = ''
xui_password: str = ''
xui_api_token: str = ''
xui_sync_users: bool = False
xui_create_conns: bool = False
xui_server_id: int = 0
xui_protocol: str = 'xray'
class CaptchaSettings(BaseModel):
enabled: bool = False
@@ -1646,6 +1905,9 @@ async def startup():
if 'expiration_date' not in u:
u['expiration_date'] = None
migrated = True
if 'xui_email' not in u:
u['xui_email'] = None
migrated = True
if migrated:
changed = True
@@ -1811,6 +2073,15 @@ async def periodic_background_tasks():
logger.info(f"Background Remnawave sync finished: {count} users updated. {msg}")
else:
logger.info("Background Remnawave sync skipped (disabled in settings)")
# --- 3. 3X-UI SYNC ---
logger.info("Starting background 3x-ui sync...")
data = load_data()
if data.get('settings', {}).get('sync', {}).get('xui_sync_users'):
count, msg = await sync_users_with_xui(data)
logger.info(f"Background 3x-ui sync finished: {count} users updated. {msg}")
else:
logger.info("Background 3x-ui sync skipped (disabled in settings)")
except Exception as e:
logger.error(f"Error in periodic_background_tasks: {e}")
@@ -3150,7 +3421,10 @@ async def api_list_users(request: Request, search: str = '', page: int = 1, size
'share_enabled': u.get('share_enabled', False),
'share_token': u.get('share_token'),
'has_share_password': bool(u.get('share_password_hash')),
'source': 'Remnawave' if u.get('remnawave_uuid') else 'Local'
'source': (
'Remnawave' if u.get('remnawave_uuid')
else ('3x-ui' if u.get('xui_email') else 'Local')
)
})
return {
'users': users,
@@ -3191,6 +3465,7 @@ async def api_add_user(request: Request, req: AddUserRequest):
'enabled': True,
'created_at': datetime.now().isoformat(),
'remnawave_uuid': None,
'xui_email': None,
'share_enabled': False,
'share_token': secrets.token_urlsafe(16),
'share_password_hash': None,
@@ -3762,6 +4037,26 @@ async def api_sync_delete(request: Request):
return {'status': 'success', 'count': len(to_delete_ids)}
@app.post('/api/settings/xui_sync_now', tags=["Settings"])
async def api_xui_sync_now(request: Request):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
data = load_data()
count, msg = await sync_users_with_xui(data)
return {'status': 'success', 'count': count, 'message': msg}
@app.post('/api/settings/xui_sync_delete', tags=["Settings"])
async def api_xui_sync_delete(request: Request):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
data = load_data()
to_delete_ids = [u['id'] for u in data['users'] if u.get('xui_email')]
if to_delete_ids:
await perform_mass_operations(delete_uids=to_delete_ids)
return {'status': 'success', 'count': len(to_delete_ids)}
@app.get('/api/servers/{server_id}/{protocol}/clients', tags=["Connections"])
async def api_get_server_clients(request: Request, server_id: int, protocol: str):
if not _check_admin(request):
+10
View File
@@ -78,5 +78,15 @@ def init_schema():
if stmt:
cur.execute(stmt)
conn.commit()
# Soft migrations for existing databases
with pool.connection() as conn:
with conn.cursor() as cur:
cur.execute(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS xui_email TEXT"
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_users_xui_email ON users(xui_email)"
)
conn.commit()
_schema_ready = True
logger.info('PostgreSQL schema ready')
+3
View File
@@ -29,11 +29,14 @@ CREATE TABLE IF NOT EXISTS users (
last_reset_at TIMESTAMPTZ,
expiration_date TIMESTAMPTZ,
remnawave_uuid TEXT,
xui_email TEXT,
share_enabled BOOLEAN NOT NULL DEFAULT FALSE,
share_token TEXT,
share_password_hash TEXT
);
CREATE INDEX IF NOT EXISTS idx_users_xui_email ON users(xui_email);
CREATE TABLE IF NOT EXISTS user_connections (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+14 -3
View File
@@ -35,6 +35,15 @@ DEFAULT_SETTINGS = {
'remnawave_create_conns': False,
'remnawave_server_id': 0,
'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',
},
}
@@ -116,6 +125,7 @@ def _row_to_user(row) -> dict:
'last_reset_at': _ts_iso(row['last_reset_at']),
'expiration_date': _ts_iso(row['expiration_date']),
'remnawave_uuid': row['remnawave_uuid'],
'xui_email': row.get('xui_email'),
'share_enabled': bool(row['share_enabled']),
'share_token': row['share_token'],
'share_password_hash': row['share_password_hash'],
@@ -163,7 +173,7 @@ def load_data() -> dict:
'SELECT id, username, password_hash, role, enabled, created_at, '
'telegram_id, email, description, traffic_limit, traffic_used, '
'traffic_total, traffic_reset_strategy, last_reset_at, '
'expiration_date, remnawave_uuid, share_enabled, share_token, '
'expiration_date, remnawave_uuid, xui_email, share_enabled, share_token, '
'share_password_hash FROM users ORDER BY created_at NULLS LAST, username'
)
users = [_row_to_user(r) for r in cur.fetchall()]
@@ -242,10 +252,10 @@ def save_data(data: dict) -> None:
'id, username, password_hash, role, enabled, created_at, '
'telegram_id, email, description, traffic_limit, traffic_used, '
'traffic_total, traffic_reset_strategy, last_reset_at, '
'expiration_date, remnawave_uuid, share_enabled, share_token, '
'expiration_date, remnawave_uuid, xui_email, share_enabled, share_token, '
'share_password_hash'
') VALUES ('
'%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s'
'%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s'
')',
(
_as_uuid(user['id']),
@@ -264,6 +274,7 @@ def save_data(data: dict) -> None:
_parse_ts(user.get('last_reset_at')),
_parse_ts(user.get('expiration_date')),
user.get('remnawave_uuid'),
user.get('xui_email'),
bool(user.get('share_enabled', False)),
user.get('share_token'),
user.get('share_password_hash'),
+188 -4
View File
@@ -249,9 +249,9 @@
style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);">
<input type="checkbox" disabled> Marzban
</label>
<label
style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);">
<input type="checkbox" disabled> 3x-ui
<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
style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);">
@@ -336,6 +336,85 @@
</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-group">
<label class="form-label">{{ _('xui_url_label') }}</label>
<input type="url" class="form-input" name="xui_url"
value="{{ settings.sync.xui_url }}" 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_api_token_label') }}</label>
<input type="password" class="form-input" name="xui_api_token"
value="{{ settings.sync.xui_api_token }}" placeholder="{{ _('xui_api_token_placeholder') }}">
<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 type="text" class="form-input" name="xui_username"
value="{{ settings.sync.xui_username }}" autocomplete="off">
</div>
<div class="form-group">
<label class="form-label">{{ _('xui_password_label') }}</label>
<input type="password" class="form-input" name="xui_password"
value="{{ settings.sync.xui_password }}" autocomplete="off">
</div>
</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>
</div>
</form>
</div>
@@ -502,15 +581,53 @@
}
}
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.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';
});
document.getElementById('syncCreateConns').addEventListener('change', (e) => {
document.getElementById('autoConnFields').style.display = e.target.checked ? 'block' : 'none';
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('sslFields').style.display = e.target.checked ? 'block' : 'none';
});
@@ -760,6 +877,64 @@
}
}
async function syncXuiNow() {
const btn = document.getElementById('xuiSyncNowBtn');
const text = document.getElementById('xuiSyncNowBtnText');
const spinner = document.getElementById('xuiSyncNowSpinner');
btn.disabled = true;
text.textContent = _('sync_running');
spinner.classList.remove('hidden');
try {
const res = await fetch('/api/settings/xui_sync_now', { method: 'POST' });
const data = await res.json();
if (data.status === 'success') {
showToast(_('sync_success').replace('{}', data.count), 'success');
if (data.message && data.count === 0) {
showToast(data.message, 'error');
} else {
setTimeout(() => window.location.reload(), 1500);
}
} else {
throw new Error(data.message || '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() {
const btn = document.getElementById('saveBtn');
const spinner = document.getElementById('saveSpinner');
@@ -782,7 +957,16 @@
remnawave_sync_users: syncForm.remnawave_sync_users.checked,
remnawave_create_conns: syncForm.remnawave_create_conns.checked,
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
};
+8
View File
@@ -212,6 +212,14 @@
"api_key_label": "API Key",
"enable_sync": "Enable synchronization",
"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_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)",
"sync_now_btn": "🔄 Sync now",
"delete_sync_btn": "🗑 Delete synchronization",
"auto_create_conns": "Create connections automatically",
+8
View File
@@ -210,6 +210,14 @@
"api_key_label": "کلید API",
"enable_sync": "فعال‌سازی همگام‌سازی",
"sync_hint": "کاربران طبق داده‌های Remnawave به‌طور خودکار مدیریت می‌شوند",
"xui_url_label": "آدرس 3x-ui",
"xui_url_hint": "شامل پروتکل، پورت و webBasePath در صورت وجود",
"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 به کاربران پنل همگام می‌شوند",
"sync_now_btn": "🔄 همگام‌سازی اکنون",
"delete_sync_btn": "🗑 حذف همگام‌سازی",
"auto_create_conns": "ایجاد خودکار اتصال‌ها",
+8
View File
@@ -210,6 +210,14 @@
"api_key_label": "Clé API",
"enable_sync": "Activer synchronisation",
"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_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)",
"sync_now_btn": "🔄 Sync maintenant",
"delete_sync_btn": "🗑 Supprimer synchro",
"auto_create_conns": "Créer connexions auto",
+8
View File
@@ -212,6 +212,14 @@
"api_key_label": "API Key",
"enable_sync": "Включить синхронизацию",
"sync_hint": "Пользователи будут создаваться, отключаться и удаляться автоматически на основе данных из Remnawave",
"xui_url_label": "URL 3x-ui",
"xui_url_hint": "Укажите схему, порт и webBasePath при наличии (пример: https://host:2053/secret)",
"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)",
"sync_now_btn": "🔄 Синхронизировать сейчас",
"delete_sync_btn": "🗑 Удалить синхронизацию",
"auto_create_conns": "Создавать подключения автоматически",
+8
View File
@@ -210,6 +210,14 @@
"api_key_label": "API 密钥",
"enable_sync": "开启同步",
"sync_hint": "将根据 Remnawave 数据自动创建、禁用和删除用户",
"xui_url_label": "3x-ui 地址",
"xui_url_hint": "包含协议、端口和 webBasePath(如有)",
"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 同步为面板用户",
"sync_now_btn": "🔄 立即同步",
"delete_sync_btn": "🗑 删除同步数据",
"auto_create_conns": "自动创建连接",