forked from test2/Amnezia-Web-Panel-main
Add 3x-ui user sync via panel API (login or Bearer token).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ایجاد خودکار اتصالها",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Создавать подключения автоматически",
|
||||
|
||||
@@ -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": "自动创建连接",
|
||||
|
||||
Reference in New Issue
Block a user