diff --git a/app.py b/app.py
index a080753..51e1de4 100644
--- a/app.py
+++ b/app.py
@@ -956,10 +956,17 @@ def _manager_call(manager, method, protocol, *args, **kwargs):
"""Unified call: WireGuard manager methods don't take protocol_type argument."""
fn = getattr(manager, method)
if isinstance(manager, WireGuardManager):
+ # WireGuard signatures omit protocol and often omit port.
+ if method in ('get_client_config', 'add_client') and len(args) >= 2:
+ return fn(args[0], args[1])
return fn(*args, **kwargs)
return fn(protocol, *args, **kwargs)
+# Protocols that own VPN clients (can list/link connections)
+CLIENT_VPN_BASES = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard'}
+
+
def generate_vpn_link(config_text):
b64 = base64.b64encode(config_text.strip().encode('utf-8')).decode('utf-8')
return f"vpn://{b64}"
@@ -3616,22 +3623,27 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo
return JSONResponse({'error': 'User not found'}, status_code=404)
if req.server_id >= len(data['servers']):
return JSONResponse({'error': 'Server not found'}, status_code=404)
+ if protocol_base(req.protocol) not in CLIENT_VPN_BASES:
+ return JSONResponse(
+ {'error': f'Protocol "{req.protocol}" does not support user connections'},
+ status_code=400,
+ )
server = data['servers'][req.server_id]
proto_info = server.get('protocols', {}).get(req.protocol, {})
port = proto_info.get('port', '55424')
ssh = get_ssh(server)
await asyncio.to_thread(ssh.connect)
- manager = get_protocol_manager(ssh, req.protocol)
-
- if req.client_id:
- # Use existing client
- target_client_id = req.client_id
- # Retrieve config for existing client
- config = await asyncio.to_thread(manager.get_client_config, req.protocol, req.client_id, server['host'], port)
- result = {'client_id': target_client_id, 'config': config}
- else:
- # Create new client
- if protocol_base(req.protocol) == 'telemt':
+ try:
+ manager = get_protocol_manager(ssh, req.protocol)
+
+ if req.client_id:
+ # Link existing client
+ config = await asyncio.to_thread(
+ _manager_call, manager, 'get_client_config',
+ req.protocol, req.client_id, server['host'], port,
+ )
+ result = {'client_id': req.client_id, 'config': config}
+ elif protocol_base(req.protocol) == 'telemt':
result = await asyncio.to_thread(
manager.add_client, req.protocol, req.name, server['host'], port,
telemt_quota=req.telemt_quota,
@@ -3639,12 +3651,17 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo
telemt_expiry=req.telemt_expiry,
secret=req.telemt_secret,
user_ad_tag=req.telemt_ad_tag,
- max_tcp_conns=req.telemt_max_conns
+ max_tcp_conns=req.telemt_max_conns,
)
+ elif protocol_base(req.protocol) == 'wireguard':
+ result = await asyncio.to_thread(manager.add_client, req.name, server['host'])
else:
- result = await asyncio.to_thread(manager.add_client, req.protocol, req.name, server['host'], port)
-
- await asyncio.to_thread(ssh.disconnect)
+ result = await asyncio.to_thread(
+ _manager_call, manager, 'add_client',
+ req.protocol, req.name, server['host'], port,
+ )
+ finally:
+ await asyncio.to_thread(ssh.disconnect)
if result.get('client_id'):
conn = {
@@ -3656,9 +3673,10 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo
'name': req.name,
'created_at': datetime.now().isoformat(),
}
- data = load_data()
- data['user_connections'].append(conn)
- save_data(data)
+ async with DATA_LOCK:
+ data = load_data()
+ data['user_connections'].append(conn)
+ save_data(data)
resp = {'status': 'success'}
if result.get('config'):
@@ -3667,7 +3685,7 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo
return resp
except Exception as e:
logger.exception("Error adding user connection")
- return JSONResponse({'error': str(e)}, status_code=500)
+ return JSONResponse({'error': str(e) or e.__class__.__name__}, status_code=500)
@app.get('/api/users/{user_id}/connections', tags=["Users"])
@@ -4092,31 +4110,52 @@ async def api_get_server_clients(request: Request, server_id: int, protocol: str
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
try:
+ if not protocol or protocol_base(protocol) not in CLIENT_VPN_BASES:
+ return JSONResponse(
+ {'error': f'Protocol "{protocol}" does not support client listing'},
+ status_code=400,
+ )
data = load_data()
if server_id >= len(data['servers']):
return JSONResponse({'error': 'Server not found'}, status_code=404)
server = data['servers'][server_id]
ssh = get_ssh(server)
- ssh.connect()
- manager = get_protocol_manager(ssh, protocol)
- clients = manager.get_clients(protocol)
- ssh.disconnect()
-
+ await asyncio.to_thread(ssh.connect)
+ try:
+ manager = get_protocol_manager(ssh, protocol)
+ if not hasattr(manager, 'get_clients'):
+ return JSONResponse(
+ {'error': f'Manager for "{protocol}" has no get_clients'},
+ status_code=400,
+ )
+ clients = await asyncio.to_thread(_manager_call, manager, 'get_clients', protocol)
+ finally:
+ await asyncio.to_thread(ssh.disconnect)
+
# Filter: only show clients that are not assigned to anyone in the panel
- assigned_ids = {c['client_id'] for c in data.get('user_connections', []) if c['server_id'] == server_id and c['protocol'] == protocol}
-
+ assigned_ids = {
+ c['client_id']
+ for c in data.get('user_connections', [])
+ if c.get('server_id') == server_id and c.get('protocol') == protocol
+ }
+
filtered = []
- for c in clients:
- if c['clientId'] not in assigned_ids:
- filtered.append({
- 'id': c['clientId'],
- 'name': c.get('userData', {}).get('clientName', 'Unnamed')
- })
-
+ for c in clients or []:
+ cid = c.get('clientId') or c.get('client_id') or c.get('id')
+ if not cid or cid in assigned_ids:
+ continue
+ filtered.append({
+ 'id': cid,
+ 'name': (c.get('userData') or {}).get('clientName')
+ or c.get('name')
+ or c.get('email')
+ or 'Unnamed',
+ })
+
return {'clients': filtered}
except Exception as e:
logger.exception("Error getting server clients")
- return JSONResponse({'error': str(e)}, status_code=500)
+ return JSONResponse({'error': str(e) or e.__class__.__name__}, status_code=500)
@app.get('/api/settings/tokens', tags=["API Tokens"])
diff --git a/templates/base.html b/templates/base.html
index 695c326..8f5e7f4 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -227,10 +227,16 @@
throw new Error('Session expired');
}
- const data = await res.json();
+ const data = await res.json().catch(() => ({}));
if (!res.ok) {
- throw new Error(data.error || 'Unknown error');
+ const detail = data.error || data.message || data.detail;
+ const msg = typeof detail === 'string'
+ ? detail
+ : (Array.isArray(detail)
+ ? detail.map(d => d.msg || JSON.stringify(d)).join('; ')
+ : (detail ? JSON.stringify(detail) : `HTTP ${res.status}`));
+ throw new Error(msg || 'Unknown error');
}
return data;
}
diff --git a/templates/users.html b/templates/users.html
index 034c8ad..6eb2a5a 100644
--- a/templates/users.html
+++ b/templates/users.html
@@ -586,6 +586,7 @@
const select = document.getElementById(selectId);
const group = groupId ? document.getElementById(groupId) : null;
select.innerHTML = '';
+ const vpnBases = new Set(['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard']);
if (serverId === '') {
if (group) group.style.display = 'none';
@@ -597,13 +598,14 @@
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())));
- select.appendChild(opt);
- count++;
- }
+ if (!info.installed) continue;
+ const base = key.split('__')[0];
+ if (!vpnBases.has(base)) continue;
+ 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 === 'wireguard' ? 'WireGuard' : (key === 'telemt' ? 'Telemt' : key.toUpperCase())))));
+ select.appendChild(opt);
+ count++;
}
if (count > 0) {
@@ -765,15 +767,26 @@
const select = document.getElementById('ucExistingClient');
select.innerHTML = ``;
+ if (!protocol) {
+ select.innerHTML = ``;
+ return;
+ }
+
try {
- const data = await apiCall(`/api/servers/${serverId}/${protocol}/clients`);
+ const data = await apiCall(`/api/servers/${serverId}/${encodeURIComponent(protocol)}/clients`);
if (!data.clients || data.clients.length === 0) {
select.innerHTML = ``;
return;
}
- select.innerHTML = '';
+ select.innerHTML = '';
data.clients.forEach(c => {
- select.innerHTML += ``;
+ const id = c.id || '';
+ const short = id ? (id.length > 8 ? id.substring(0, 8) + '...' : id) : '';
+ const label = `${c.name || 'Unnamed'}${short ? ' (' + short + ')' : ''}`;
+ const opt = document.createElement('option');
+ opt.value = id;
+ opt.textContent = label;
+ select.appendChild(opt);
});
} catch (err) {
select.innerHTML = ``;
diff --git a/translations/en.json b/translations/en.json
index 70643a7..05eae47 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -158,6 +158,7 @@
"conn_name_panel": "Connection name (in panel)",
"user_conns_title": "User connections",
"no_free_conns": "No free connections",
+ "select_connection": "Please select a connection",
"linking": "Linking...",
"conn_linked": "Connection linked",
"enabling": "Enabling",
diff --git a/translations/fa.json b/translations/fa.json
index 4fcfef6..5058171 100644
--- a/translations/fa.json
+++ b/translations/fa.json
@@ -156,6 +156,7 @@
"conn_name_panel": "نام اتصال (در پنل)",
"user_conns_title": "اتصالهای کاربر",
"no_free_conns": "اتصال آزاد موجود نیست",
+ "select_connection": "لطفا یک اتصال انتخاب کنید",
"linking": "در حال اتصال...",
"conn_linked": "اتصال برقرار شد",
"enabling": "در حال فعالسازی",
diff --git a/translations/fr.json b/translations/fr.json
index 0a74ec4..6f4afec 100644
--- a/translations/fr.json
+++ b/translations/fr.json
@@ -156,6 +156,7 @@
"conn_name_panel": "Nom (dans le panneau)",
"user_conns_title": "Connexions utilisateur",
"no_free_conns": "Aucune connexion libre",
+ "select_connection": "Veuillez sélectionner une connexion",
"linking": "Liaison...",
"conn_linked": "Connexion liée",
"enabling": "Activation",
diff --git a/translations/ru.json b/translations/ru.json
index fc239f1..20322a9 100644
--- a/translations/ru.json
+++ b/translations/ru.json
@@ -158,6 +158,7 @@
"conn_name_panel": "Имя подключения (в панели)",
"user_conns_title": "Подключения пользователя",
"no_free_conns": "Нет свободных подключений",
+ "select_connection": "Выберите подключение",
"linking": "Привязка...",
"conn_linked": "Подключение привязано",
"enabling": "Включение",
diff --git a/translations/zh.json b/translations/zh.json
index ef69fd2..d0a1a35 100644
--- a/translations/zh.json
+++ b/translations/zh.json
@@ -156,6 +156,7 @@
"conn_name_panel": "连接名称 (面板显示)",
"user_conns_title": "用户连接列表",
"no_free_conns": "无可用空闲连接",
+ "select_connection": "请选择一个连接",
"linking": "关联中...",
"conn_linked": "连接关联成功",
"enabling": "启用中",