From c4181abb2af48cf05a71f051cafe23d008ecb197 Mon Sep 17 00:00:00 2001 From: leonidorlov-hash Date: Sat, 1 Aug 2026 16:19:15 +0300 Subject: [PATCH 01/23] Fix peer IP allocation: fill subnet gaps, sort peers by IP, backup config - _get_next_ip now returns the first free address in the subnet instead of incrementing the last IP found in file order, which could hand out duplicate IPs and never reused addresses freed by deleted clients - new peers are inserted into the server config keeping [Peer] sections sorted by AllowedIPs - a timestamped backup of the config is created inside the container before it is overwritten --- managers/awg_manager.py | 93 ++++++++++++++++++++++++++--------- managers/wireguard_manager.py | 90 +++++++++++++++++++++++++-------- 2 files changed, 140 insertions(+), 43 deletions(-) diff --git a/managers/awg_manager.py b/managers/awg_manager.py index f0125f1..cd90ca6 100644 --- a/managers/awg_manager.py +++ b/managers/awg_manager.py @@ -811,28 +811,78 @@ tail -f /dev/null return ips def _get_next_ip(self, protocol_type): - """Calculate the next available IP for a new client.""" + """Return the first free IP in the subnet, filling gaps left by deleted clients. + + The old implementation took the last IP in file order and incremented it, + which produced duplicate IPs when peers were not sorted by IP and never + reused addresses freed by deleted clients. + """ used_ips = self._get_used_ips(protocol_type) - if not used_ips: - base = self._get_subnet_base(protocol_type) - parts = base.split('.') - parts[3] = '2' - return '.'.join(parts) + base = self._get_subnet_base(protocol_type) + parts = base.split('.') + prefix = '.'.join(parts[:3]) - # Get the last used IP and increment - last_ip = used_ips[-1] - parts = last_ip.split('.') - last_octet = int(parts[3]) + used_octets = set() + for ip in used_ips: + ip_parts = ip.split('.') + if len(ip_parts) != 4 or '.'.join(ip_parts[:3]) != prefix: + continue + try: + used_octets.add(int(ip_parts[3])) + except ValueError: + continue - if last_octet == 254: - next_octet = last_octet + 3 - elif last_octet == 255: - next_octet = last_octet + 2 - else: - next_octet = last_octet + 1 + for octet in range(2, 255): + if octet not in used_octets: + parts[3] = str(octet) + return '.'.join(parts) - parts[3] = str(next_octet) - return '.'.join(parts) + raise RuntimeError("No free IP addresses left in the subnet") + + @staticmethod + def _peer_block_ip(block): + """Sort key for a [Peer] config block: its first AllowedIPs IPv4 address.""" + match = re.search(r'AllowedIPs\s*=\s*(\d+)\.(\d+)\.(\d+)\.(\d+)', block) + if match: + return tuple(int(match.group(i)) for i in range(1, 5)) + return (255, 255, 255, 255) + + def _insert_peer_sorted(self, protocol_type, peer_section): + """Insert a new [Peer] section into the server config keeping peers sorted by IP. + + Creates a timestamped backup of the config inside the container before + overwriting it, then rewrites the file with all [Peer] sections ordered + by their AllowedIPs address. + """ + container_name = self._container_name(protocol_type) + config_path = self._resolve_config_path(protocol_type) + + config = self._get_server_config(protocol_type) + + # Backup current config inside the container before modifying it + ts = __import__('datetime').datetime.now().strftime('%Y%m%d_%H%M%S') + self.ssh.run_sudo_command( + f"docker exec -i {container_name} cp {config_path} {config_path}.bak.{ts}" + ) + + head, _, rest = config.partition('[Peer]') + blocks = [] + if rest: + for chunk in rest.split('[Peer]'): + chunk = chunk.strip() + if chunk: + blocks.append('[Peer]\n' + chunk) + + blocks.append(peer_section.strip()) + blocks.sort(key=self._peer_block_ip) + + new_config = head.rstrip('\n') + '\n\n' + '\n\n'.join(blocks) + '\n' + + self.ssh.upload_file(new_config, "/tmp/_amnz_add_peer.conf") + self.ssh.run_sudo_command( + f"docker cp /tmp/_amnz_add_peer.conf {container_name}:{config_path}" + ) + self.ssh.run_command("rm -f /tmp/_amnz_add_peer.conf") def _extract_ipv4(self, value): """Extract the first IPv4 address from AllowedIPs/clientIp-like values.""" @@ -1012,11 +1062,8 @@ PresharedKey = {psk} AllowedIPs = {client_ip}/32 """ - # Append peer to server config - escaped_peer = peer_section.replace("'", "'\\''") - self.ssh.run_sudo_command( - f"docker exec -i {container_name} bash -c 'echo \"{escaped_peer}\" >> {config_path}'" - ) + # Insert peer into server config, keeping peers sorted by IP (with backup) + self._insert_peer_sorted(protocol_type, peer_section) # Sync config without restart self.ssh.run_sudo_command( diff --git a/managers/wireguard_manager.py b/managers/wireguard_manager.py index b646cd3..8bcf562 100644 --- a/managers/wireguard_manager.py +++ b/managers/wireguard_manager.py @@ -426,22 +426,75 @@ tail -f /dev/null return ips def _get_next_ip(self): - """Calculate the next available IP for a new client.""" - used_ips = self._get_used_ips() - if not used_ips: - base = WG_DEFAULTS['subnet_address'] - parts = base.split('.') - parts[3] = '2' - return '.'.join(parts) + """Return the first free IP in the subnet, filling gaps left by deleted clients. - last_ip = used_ips[-1] - parts = last_ip.split('.') - last_octet = int(parts[3]) - next_octet = last_octet + 1 - if next_octet > 254: - next_octet = 2 - parts[3] = str(next_octet) - return '.'.join(parts) + The old implementation took the last IP in file order and incremented it, + which produced duplicate IPs when peers were not sorted by IP and never + reused addresses freed by deleted clients. + """ + used_ips = self._get_used_ips() + base = WG_DEFAULTS['subnet_address'] + parts = base.split('.') + prefix = '.'.join(parts[:3]) + + used_octets = set() + for ip in used_ips: + ip_parts = ip.split('.') + if len(ip_parts) != 4 or '.'.join(ip_parts[:3]) != prefix: + continue + try: + used_octets.add(int(ip_parts[3])) + except ValueError: + continue + + for octet in range(2, 255): + if octet not in used_octets: + parts[3] = str(octet) + return '.'.join(parts) + + raise RuntimeError("No free IP addresses left in the subnet") + + @staticmethod + def _peer_block_ip(block): + """Sort key for a [Peer] config block: its first AllowedIPs IPv4 address.""" + match = re.search(r'AllowedIPs\s*=\s*(\d+)\.(\d+)\.(\d+)\.(\d+)', block) + if match: + return tuple(int(match.group(i)) for i in range(1, 5)) + return (255, 255, 255, 255) + + def _insert_peer_sorted(self, peer_section): + """Insert a new [Peer] section into the server config keeping peers sorted by IP. + + Creates a timestamped backup of the config inside the container before + overwriting it, then rewrites the file with all [Peer] sections ordered + by their AllowedIPs address. + """ + config = self._get_server_config() + + # Backup current config inside the container before modifying it + ts = __import__('datetime').datetime.now().strftime('%Y%m%d_%H%M%S') + self.ssh.run_sudo_command( + f"docker exec -i {self.CONTAINER_NAME} cp {self.CONFIG_PATH} {self.CONFIG_PATH}.bak.{ts}" + ) + + head, _, rest = config.partition('[Peer]') + blocks = [] + if rest: + for chunk in rest.split('[Peer]'): + chunk = chunk.strip() + if chunk: + blocks.append('[Peer]\n' + chunk) + + blocks.append(peer_section.strip()) + blocks.sort(key=self._peer_block_ip) + + new_config = head.rstrip('\n') + '\n\n' + '\n\n'.join(blocks) + '\n' + + self.ssh.upload_file(new_config, "/tmp/_wg_add_peer.conf") + self.ssh.run_sudo_command( + f"docker cp /tmp/_wg_add_peer.conf {self.CONTAINER_NAME}:{self.CONFIG_PATH}" + ) + self.ssh.run_command("rm -f /tmp/_wg_add_peer.conf") def _parse_peers_from_config(self): """Parse [Peer] sections from WireGuard server config.""" @@ -593,7 +646,6 @@ tail -f /dev/null mtu = WG_DEFAULTS['mtu'] - # Append peer to server config peer_section = f""" [Peer] PublicKey = {client_pub_key} @@ -601,10 +653,8 @@ PresharedKey = {psk} AllowedIPs = {client_ip}/32 """ - escaped_peer = peer_section.replace("'", "'\\''") - self.ssh.run_sudo_command( - f"docker exec -i {self.CONTAINER_NAME} bash -c 'echo \"{escaped_peer}\" >> {self.CONFIG_PATH}'" - ) + # Insert peer into server config, keeping peers sorted by IP (with backup) + self._insert_peer_sorted(peer_section) # Sync config without restart self.ssh.run_sudo_command( From 8f31a8db3b2fb31289facf2bd0fa083ae6f50f9a Mon Sep 17 00:00:00 2001 From: leonidorlov-hash Date: Wed, 5 Aug 2026 00:59:05 +0300 Subject: [PATCH 02/23] feat: add rename_client to TelemtManager --- managers/telemt_manager.py | 53 +++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/managers/telemt_manager.py b/managers/telemt_manager.py index 70d8c89..1afae84 100644 --- a/managers/telemt_manager.py +++ b/managers/telemt_manager.py @@ -392,7 +392,7 @@ docker compose version api_payload['max_tcp_conns'] = val # Save config to host - self.ssh.upload_file_sudo(config_text.replace('\r\n', '\n'), f"{self._config_path()}") + self.ssh.upload_file_sudo(config_content.replace('\r\n', '\n'), f"{self._config_path()}") # 2. Call API for immediate effect self._api_request("POST", "/v1/users", data=api_payload) @@ -449,7 +449,7 @@ docker compose version api_payload['max_tcp_conns'] = val # Save config to host - self.ssh.upload_file_sudo(config_text.replace('\r\n', '\n'), f"{self._config_path()}") + self.ssh.upload_file_sudo(config_content.replace('\r\n', '\n'), f"{self._config_path()}") # API call self._api_request("PATCH", f"/v1/users/{client_id}", data=api_payload) @@ -554,6 +554,53 @@ docker compose version if restart: self.ssh.run_sudo_command(f"docker kill -s HUP {self.container_name} || docker restart {self.container_name}") + def rename_client(self, protocol_type, client_id, new_name): + """Rename a Telemt user. The username *is* the client identity here, + so every `username = ...` line in all access.* sections is rewritten + (preserving the `#` disabled marker) and the runtime API user is + re-created under the new name with the same secret. Proxy links are + secret-based, so existing links keep working.""" + new_username = re.sub(r'[^a-zA-Z0-9_.-]', '', (new_name or '').replace(' ', '_')) + if not new_username: + raise RuntimeError('Invalid name') + if new_username == client_id: + return {'status': 'success', 'name': new_username} + + config_text = self._get_server_config() + users = self._parse_users_from_config(config_text) + entry_name = next((u for u in users if u.lstrip('#').strip() == client_id), None) + if entry_name is None: + raise RuntimeError('Client not found') + if any(u.lstrip('#').strip() == new_username for u in users): + raise RuntimeError('A client with this name already exists') + + secret = users[entry_name] + enabled = not entry_name.startswith('#') + + lines = config_text.split('\n') + in_access_section = False + for i, line in enumerate(lines): + stripped = line.strip() + if stripped.startswith('[access.'): + in_access_section = True + elif stripped.startswith('['): + in_access_section = False + if not in_access_section: + continue + commented = stripped.startswith('#') + content = stripped.lstrip('#').strip() + if content.startswith(f'{client_id} =') or content.startswith(f'{client_id}='): + new_line = content.replace(client_id, new_username, 1) + lines[i] = f'# {new_line}' if commented else new_line + self.ssh.upload_file_sudo('\n'.join(lines).replace('\r\n', '\n'), f"{self._config_path()}") + + # Sync the runtime: the API keys users by username, so swap the identity. + self._api_request("DELETE", f"/v1/users/{client_id}") + if enabled: + self._api_request("POST", "/v1/users", data={"username": new_username, "secret": secret}) + self.ssh.run_sudo_command(f"docker kill -s HUP {self.container_name} || docker restart {self.container_name}") + return {'status': 'success', 'name': new_username, 'client_id': new_username} + def get_client_config(self, protocol_type, client_id, host='', port=''): resp = self._api_request("GET", f"/v1/users/{client_id}") if resp and resp.get('ok'): @@ -568,4 +615,4 @@ docker compose version if c: secret = c.get('userData', {}).get('token', '') if secret: return f"tg://proxy?server={host}&port={port}&secret={secret}" - return "Not found" \ No newline at end of file + return "Not found" From 3c4d6dcb328ad37124a568f883f3ae4ea56f5d20 Mon Sep 17 00:00:00 2001 From: leonidorlov-hash Date: Wed, 5 Aug 2026 01:03:09 +0300 Subject: [PATCH 03/23] feat: add rename_client to XrayManager and WireGuardManager --- managers/wireguard_manager.py | 26 ++++++++++++++++++++++++++ managers/xray_manager.py | 13 +++++++++++++ 2 files changed, 39 insertions(+) diff --git a/managers/wireguard_manager.py b/managers/wireguard_manager.py index b646cd3..e114f9d 100644 --- a/managers/wireguard_manager.py +++ b/managers/wireguard_manager.py @@ -767,6 +767,32 @@ AllowedIPs = {client_ip}/32 self._save_clients_table(clients_table) return True + def rename_client(self, client_id, new_name): + """Rename a client. The name lives only in the clientsTable + (userData.clientName); keys, IPs and the WireGuard config itself + are untouched, so existing configs keep working.""" + clients_table = self._get_clients_table() + client = next((c for c in clients_table if c.get('clientId') == client_id), None) + if client is None: + # Peer added via the native app is not in the table yet — + # persist a minimal entry so the chosen name sticks. + conf_peers = self._parse_peers_from_config() + if client_id not in conf_peers: + raise RuntimeError('Client not found') + client = { + 'clientId': client_id, + 'userData': { + 'clientName': new_name, + 'clientPrivateKey': '', + 'externalClient': True, + } + } + clients_table.append(client) + else: + client.setdefault('userData', {})['clientName'] = new_name + self._save_clients_table(clients_table) + return {'status': 'success', 'name': new_name} + def get_server_status(self): """Get detailed status of the WireGuard server.""" info = { diff --git a/managers/xray_manager.py b/managers/xray_manager.py index 3483fa5..9bdd334 100644 --- a/managers/xray_manager.py +++ b/managers/xray_manager.py @@ -798,3 +798,16 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ] clients_table = [c for c in clients_table if c['clientId'] != client_id] self._save_clients_table(clients_table) return True + + def rename_client(self, protocol, client_id, new_name): + """Rename a client. The display name lives in clientsTable + (userData.clientName) and is embedded into the VLESS URL fragment on + the fly, so updating the table is enough — UUID, keys and server.json + stay untouched and existing links keep working.""" + clients_table = self._get_clients_table() + client = next((c for c in clients_table if c.get('clientId') == client_id), None) + if client is None: + raise RuntimeError('Client not found') + client.setdefault('userData', {})['clientName'] = new_name + self._save_clients_table(clients_table) + return {'status': 'success', 'name': new_name} From 3dc33df98e052baf7a99586805e701a91434cfb9 Mon Sep 17 00:00:00 2001 From: leonidorlov-hash Date: Wed, 5 Aug 2026 01:06:55 +0300 Subject: [PATCH 04/23] feat: add rename_client to AWGManager (awg/awg2/awg_legacy, all instances) --- managers/awg_manager.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/managers/awg_manager.py b/managers/awg_manager.py index f0125f1..b9f0c4c 100644 --- a/managers/awg_manager.py +++ b/managers/awg_manager.py @@ -1349,6 +1349,32 @@ AllowedIPs = {client_ip}/32 return True + def rename_client(self, protocol_type, client_id, new_name): + """Rename a client. The name lives only in the clientsTable + (userData.clientName); keys, IPs and the WireGuard config itself + are untouched, so existing configs keep working.""" + clients_table = self._get_clients_table(protocol_type) + client = next((c for c in clients_table if c.get('clientId') == client_id), None) + if client is None: + # Peer added via the native Amnezia app is not in the table yet — + # persist a minimal entry so the chosen name sticks. + conf_peers = self._parse_peers_from_config(protocol_type) + if client_id not in conf_peers: + raise RuntimeError('Client not found') + client = { + 'clientId': client_id, + 'userData': { + 'clientName': new_name, + 'clientPrivateKey': '', + 'externalClient': True, + } + } + clients_table.append(client) + else: + client.setdefault('userData', {})['clientName'] = new_name + self._save_clients_table(protocol_type, clients_table) + return {'status': 'success', 'name': new_name} + def get_server_status(self, protocol_type): """Get detailed status of the AWG server.""" container_name = self._container_name(protocol_type) @@ -1375,4 +1401,4 @@ AllowedIPs = {client_ip}/32 except Exception as e: info['error'] = str(e) - return info \ No newline at end of file + return info From b254d7f8ee13ebc88a812fc81376c4e57d25790e Mon Sep 17 00:00:00 2001 From: leonidorlov-hash Date: Wed, 5 Aug 2026 02:20:39 +0300 Subject: [PATCH 05/23] feat: inline rename UI in connections list (pencil -> input -> save) with configModal retitle --- templates/server.html | 53 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/templates/server.html b/templates/server.html index 4a06506..2d1bfc0 100644 --- a/templates/server.html +++ b/templates/server.html @@ -950,6 +950,7 @@ let currentConfig = ''; let currentVpnLink = ''; let currentConfigName = 'connection'; + let currentConfigClientId = null; let installedProtocols = {}; let currentProtocolStatus = {}; let installedAppsLoading = true; @@ -2063,8 +2064,9 @@
+ - ${proto === 'telemt' ? `` : ''} + ${proto === 'telemt' ? `` : ''}
@@ -2190,8 +2192,57 @@ } } + function startRenameConnection(clientId, btn) { + const item = btn.closest('.client-item'); + const nameEl = item.querySelector('.client-name'); + const oldName = nameEl.textContent; + nameEl.innerHTML = ` + + + + + + `; + const input = nameEl.querySelector('input'); + const saveBtn = nameEl.querySelector('button:not(.rename-cancel)'); + const cancelBtn = nameEl.querySelector('.rename-cancel'); + saveBtn.addEventListener('click', () => saveRenameConnection(clientId, input.value)); + cancelBtn.addEventListener('click', () => loadConnections()); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') saveRenameConnection(clientId, input.value); + if (e.key === 'Escape') loadConnections(); + }); + input.focus(); + input.select(); + } + + async function saveRenameConnection(clientId, rawName) { + const proto = document.getElementById('connProtoSelect').value; + const newName = (rawName || '').trim(); + if (!newName) { + showToast(_('enter_connection_name'), 'error'); + return; + } + try { + const result = await apiCall(`/api/servers/${SERVER_ID}/connections/rename`, 'POST', { + protocol: proto, client_id: clientId, new_name: newName, + }); + showToast(_('connection_renamed'), 'success'); + // If the config modal is open for this connection, retitle it too. + if (currentConfigClientId === clientId && + document.getElementById('configModal').classList.contains('active')) { + document.getElementById('configModalTitle').textContent = `${_('config')} — ${result.name}`; + currentConfigName = result.name.replace(/\s+/g, '_'); + } + loadConnections(); + } catch (err) { + showToast(_('error') + ': ' + err.message, 'error'); + } + } + async function showConnectionConfig(clientId, connName, hasKey) { const proto = document.getElementById('connProtoSelect').value; + currentConfigClientId = clientId; // Restore tabs visibility first document.getElementById('panel-vpn').style.display = (proto === 'telemt' ? 'none' : ''); From 43f601934f647338e9b9faa18133af813d8b5aca Mon Sep 17 00:00:00 2001 From: leonidorlov-hash Date: Wed, 5 Aug 2026 02:20:43 +0300 Subject: [PATCH 06/23] feat: POST /connections/rename endpoint + RenameConnectionRequest model --- app.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/app.py b/app.py index 013e76b..4769163 100644 --- a/app.py +++ b/app.py @@ -1506,6 +1506,12 @@ class ConnectionActionRequest(BaseModel): client_id: str = '' +class RenameConnectionRequest(BaseModel): + protocol: str = 'awg' + client_id: str = '' + new_name: str = '' + + class ToggleConnectionRequest(BaseModel): protocol: str = 'awg' client_id: str = '' @@ -3082,6 +3088,42 @@ async def api_edit_connection(request: Request, server_id: int, req: EditConnect return JSONResponse({'error': str(e)}, status_code=500) +@app.post('/api/servers/{server_id}/connections/rename', tags=["Connections"]) +async def api_rename_connection(request: Request, server_id: int, req: RenameConnectionRequest): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + new_name = (req.new_name or '').strip() + if not new_name: + return JSONResponse({'error': 'New name is required'}, status_code=400) + if not req.client_id: + return JSONResponse({'error': 'Client ID is required'}, status_code=400) + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, req.protocol) + result = _manager_call(manager, 'rename_client', req.protocol, req.client_id, new_name) or {} + ssh.disconnect() + # Telemt rename may also change client_id (username is the identity there) + new_client_id = result.get('client_id', req.client_id) + stored_name = result.get('name', new_name) + changed = False + for conn in data.get('user_connections', []): + if conn.get('client_id') == req.client_id and conn.get('server_id') == server_id and conn.get('protocol') == req.protocol: + conn['name'] = stored_name + conn['client_id'] = new_client_id + changed = True + if changed: + save_data(data) + return {'status': 'success', 'name': stored_name, 'client_id': new_client_id} + except Exception as e: + logger.exception("Error renaming connection") + return JSONResponse({'error': str(e)}, status_code=500) + + @app.post('/api/servers/{server_id}/connections/config', tags=["Connections"]) async def api_get_connection_config(request: Request, server_id: int, req: ConnectionActionRequest): user = get_current_user(request) From 73deec212aaea001808e48b6f79a3ca81a79eb4f Mon Sep 17 00:00:00 2001 From: leonidorlov-hash Date: Wed, 5 Aug 2026 02:20:45 +0300 Subject: [PATCH 07/23] i18n: rename keys (en) --- translations/en.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/translations/en.json b/translations/en.json index cf10d58..87df516 100644 --- a/translations/en.json +++ b/translations/en.json @@ -419,5 +419,8 @@ "backup_created": "Backup created", "no_backups": "No backups yet", "no_backups_desc": "Create the first backup for this protocol.", - "refresh": "Refresh" -} + "refresh": "Refresh", + "rename": "Rename", + "connection_renamed": "Connection renamed", + "enter_connection_name": "Enter a new connection name" +} \ No newline at end of file From 824e86f99407aee7f3cc53b90b1b800c5676202c Mon Sep 17 00:00:00 2001 From: leonidorlov-hash Date: Wed, 5 Aug 2026 02:20:48 +0300 Subject: [PATCH 08/23] i18n: rename keys (ru) --- translations/ru.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/translations/ru.json b/translations/ru.json index e55b9b5..fc5c419 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -419,5 +419,8 @@ "backup_created": "Бекап создан", "no_backups": "Бекапов пока нет", "no_backups_desc": "Создайте первый бекап для этого протокола.", - "refresh": "Обновить" -} + "refresh": "Обновить", + "rename": "Переименовать", + "connection_renamed": "Подключение переименовано", + "enter_connection_name": "Введите новое имя подключения" +} \ No newline at end of file From 0b37b8fcad29d5c971276acdf1eb3e18e5b49dd4 Mon Sep 17 00:00:00 2001 From: leonidorlov-hash Date: Wed, 5 Aug 2026 02:20:50 +0300 Subject: [PATCH 09/23] i18n: rename keys (fr) --- translations/fr.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/translations/fr.json b/translations/fr.json index 4f4bdc3..b1d5811 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -386,5 +386,8 @@ "backup_created": "Backup created", "no_backups": "No backups yet", "no_backups_desc": "Create the first backup for this protocol.", - "refresh": "Refresh" -} + "refresh": "Refresh", + "rename": "Renommer", + "connection_renamed": "Connexion renommée", + "enter_connection_name": "Saisir un nouveau nom de connexion" +} \ No newline at end of file From fb366ee6e30a2a876cb70f402a1effc4e2af06d0 Mon Sep 17 00:00:00 2001 From: leonidorlov-hash Date: Wed, 5 Aug 2026 02:20:53 +0300 Subject: [PATCH 10/23] i18n: rename keys (fa) --- translations/fa.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/translations/fa.json b/translations/fa.json index fd7096c..2528645 100644 --- a/translations/fa.json +++ b/translations/fa.json @@ -386,5 +386,8 @@ "backup_created": "Backup created", "no_backups": "No backups yet", "no_backups_desc": "Create the first backup for this protocol.", - "refresh": "Refresh" -} + "refresh": "Refresh", + "rename": "تغییر نام", + "connection_renamed": "اتصال تغییر نام یافت", + "enter_connection_name": "نام جدید اتصال را وارد کنید" +} \ No newline at end of file From 47bd6576a740d903f08bbd8e010dd35ec37dc903 Mon Sep 17 00:00:00 2001 From: leonidorlov-hash Date: Wed, 5 Aug 2026 02:20:56 +0300 Subject: [PATCH 11/23] i18n: rename keys (zh) --- translations/zh.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/translations/zh.json b/translations/zh.json index a611051..938922f 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -386,5 +386,8 @@ "backup_created": "Backup created", "no_backups": "No backups yet", "no_backups_desc": "Create the first backup for this protocol.", - "refresh": "Refresh" -} + "refresh": "Refresh", + "rename": "重命名", + "connection_renamed": "连接已重命名", + "enter_connection_name": "请输入新的连接名称" +} \ No newline at end of file From c6c7896be2e8c05ee34ea0a6c2a084ecfc36da72 Mon Sep 17 00:00:00 2001 From: leonidorlov-hash Date: Wed, 5 Aug 2026 03:06:26 +0300 Subject: [PATCH 12/23] feat(awg): DNS from server config, per-client customConfig + save_client_config --- managers/awg_manager.py | 74 +++++++++++++++++++++++++++++++---------- 1 file changed, 56 insertions(+), 18 deletions(-) diff --git a/managers/awg_manager.py b/managers/awg_manager.py index b9f0c4c..a559392 100644 --- a/managers/awg_manager.py +++ b/managers/awg_manager.py @@ -1044,20 +1044,14 @@ AllowedIPs = {client_ip}/32 if awg_params.get('port'): port = awg_params['port'] - dns1 = AWG_DEFAULTS['dns1'] - dns2 = AWG_DEFAULTS['dns2'] - - # Check if AmneziaDNS is installed - out, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'") - if 'amnezia-dns' in out: - dns1 = '172.29.172.254' - + dns = self._get_dns(protocol_type) + mtu = AWG_DEFAULTS['mtu'] # Standard fields config_lines = [ f"Address = {client_ip}/32", - f"DNS = {dns1}, {dns2}", + f"DNS = {dns}", f"PrivateKey = {client_priv_key}", f"MTU = {mtu}" ] @@ -1121,6 +1115,8 @@ PersistentKeepalive = 25 raise RuntimeError(f"Client {client_id} not found") ud = client.get('userData', {}) + if ud.get('customConfig'): + return ud['customConfig'] client_priv_key = ud.get('clientPrivateKey', '') client_ip = ud.get('clientIp', '') psk = ud.get('psk', '') @@ -1136,20 +1132,14 @@ PersistentKeepalive = 25 if awg_params.get('port'): port = awg_params['port'] - dns1 = AWG_DEFAULTS['dns1'] - dns2 = AWG_DEFAULTS['dns2'] - - # Check if AmneziaDNS is installed - out, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'") - if 'amnezia-dns' in out: - dns1 = '172.29.172.254' - + dns = self._get_dns(protocol_type, ud) + mtu = AWG_DEFAULTS['mtu'] # Standard fields config_lines = [ f"Address = {client_ip}/32", - f"DNS = {dns1}, {dns2}", + f"DNS = {dns}", f"PrivateKey = {client_priv_key}", f"MTU = {mtu}" ] @@ -1349,6 +1339,54 @@ AllowedIPs = {client_ip}/32 return True + def _get_dns(self, protocol_type, user_data=None): + """DNS servers for generated client configs. + + Priority: per-client override (userData.dns, set by saving an edited + config) > `DNS = ...` line in the server config (wg-quick-style key, + stripped by awg-quick so it does not affect syncconf) > AmneziaDNS + container address > built-in defaults. + """ + if user_data and user_data.get('dns'): + return user_data['dns'] + try: + server_config = self._get_server_config(protocol_type) + for line in server_config.split('\n'): + stripped = line.strip() + if stripped.startswith('DNS') and '=' in stripped: + return stripped.split('=', 1)[1].strip() + except Exception: + pass + dns1 = AWG_DEFAULTS['dns1'] + dns2 = AWG_DEFAULTS['dns2'] + out, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'") + if 'amnezia-dns' in out: + dns1 = '172.29.172.254' + return f"{dns1}, {dns2}" + + def save_client_config(self, protocol_type, client_id, config_text): + """Persist a manually edited client config. Stored verbatim in + clientsTable (userData.customConfig) and returned by get_client_config + from now on; the DNS line is indexed into userData.dns as the + per-client override for future regenerations.""" + config_text = (config_text or '').strip() + if not config_text: + raise RuntimeError('Config is empty') + clients_table = self._get_clients_table(protocol_type) + client = next((c for c in clients_table if c.get('clientId') == client_id), None) + if client is None: + raise RuntimeError('Client not found') + ud = client.setdefault('userData', {}) + ud['customConfig'] = config_text + ud.pop('dns', None) + for line in config_text.split('\n'): + stripped = line.strip() + if stripped.startswith('DNS') and '=' in stripped: + ud['dns'] = stripped.split('=', 1)[1].strip() + break + self._save_clients_table(protocol_type, clients_table) + return {'status': 'success'} + def rename_client(self, protocol_type, client_id, new_name): """Rename a client. The name lives only in the clientsTable (userData.clientName); keys, IPs and the WireGuard config itself From a670e6f45e818c91a9a51dc88100e786d6af87aa Mon Sep 17 00:00:00 2001 From: leonidorlov-hash Date: Wed, 5 Aug 2026 03:06:29 +0300 Subject: [PATCH 13/23] feat(wg): DNS from server config, per-client customConfig + save_client_config --- managers/wireguard_manager.py | 70 ++++++++++++++++++++++++++--------- 1 file changed, 52 insertions(+), 18 deletions(-) diff --git a/managers/wireguard_manager.py b/managers/wireguard_manager.py index e114f9d..224a2c0 100644 --- a/managers/wireguard_manager.py +++ b/managers/wireguard_manager.py @@ -583,14 +583,8 @@ tail -f /dev/null # Get next available IP client_ip = self._get_next_ip() - dns1 = WG_DEFAULTS['dns1'] - dns2 = WG_DEFAULTS['dns2'] - - # Check if AmneziaDNS is installed - out, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'") - if 'amnezia-dns' in out: - dns1 = '172.29.172.254' - + dns = self._get_dns() + mtu = WG_DEFAULTS['mtu'] # Append peer to server config @@ -630,7 +624,7 @@ AllowedIPs = {client_ip}/32 # Build client config client_config = f"""[Interface] Address = {client_ip}/32 -DNS = {dns1}, {dns2} +DNS = {dns} PrivateKey = {client_priv_key} MTU = {mtu} @@ -656,6 +650,8 @@ PersistentKeepalive = 25 raise RuntimeError(f"Client {client_id} not found") ud = client.get('userData', {}) + if ud.get('customConfig'): + return ud['customConfig'] client_priv_key = ud.get('clientPrivateKey', '') client_ip = ud.get('clientIp', '') psk = ud.get('psk', '') @@ -669,19 +665,13 @@ PersistentKeepalive = 25 port = self._get_listen_port() - dns1 = WG_DEFAULTS['dns1'] - dns2 = WG_DEFAULTS['dns2'] - - # Check if AmneziaDNS is installed - out, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'") - if 'amnezia-dns' in out: - dns1 = '172.29.172.254' - + dns = self._get_dns(ud) + mtu = WG_DEFAULTS['mtu'] config = f"""[Interface] Address = {client_ip}/32 -DNS = {dns1}, {dns2} +DNS = {dns} PrivateKey = {client_priv_key} MTU = {mtu} @@ -767,6 +757,50 @@ AllowedIPs = {client_ip}/32 self._save_clients_table(clients_table) return True + def _get_dns(self, user_data=None): + """DNS servers for generated client configs. + + Priority: per-client override (userData.dns) > `DNS = ...` line in the + server config > AmneziaDNS container address > built-in defaults. + """ + if user_data and user_data.get('dns'): + return user_data['dns'] + try: + server_config = self._get_server_config() + for line in server_config.split('\n'): + stripped = line.strip() + if stripped.startswith('DNS') and '=' in stripped: + return stripped.split('=', 1)[1].strip() + except Exception: + pass + dns1 = WG_DEFAULTS['dns1'] + dns2 = WG_DEFAULTS['dns2'] + out, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'") + if 'amnezia-dns' in out: + dns1 = '172.29.172.254' + return f"{dns1}, {dns2}" + + def save_client_config(self, client_id, config_text): + """Persist a manually edited client config in clientsTable + (userData.customConfig + userData.dns override).""" + config_text = (config_text or '').strip() + if not config_text: + raise RuntimeError('Config is empty') + clients_table = self._get_clients_table() + client = next((c for c in clients_table if c.get('clientId') == client_id), None) + if client is None: + raise RuntimeError('Client not found') + ud = client.setdefault('userData', {}) + ud['customConfig'] = config_text + ud.pop('dns', None) + for line in config_text.split('\n'): + stripped = line.strip() + if stripped.startswith('DNS') and '=' in stripped: + ud['dns'] = stripped.split('=', 1)[1].strip() + break + self._save_clients_table(clients_table) + return {'status': 'success'} + def rename_client(self, client_id, new_name): """Rename a client. The name lives only in the clientsTable (userData.clientName); keys, IPs and the WireGuard config itself From 83fb6d0fa8f219f56e52d8cce78276cea1316bd0 Mon Sep 17 00:00:00 2001 From: leonidorlov-hash Date: Wed, 5 Aug 2026 03:06:32 +0300 Subject: [PATCH 14/23] feat: POST /connections/config/save endpoint --- app.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/app.py b/app.py index 4769163..5d9a4f2 100644 --- a/app.py +++ b/app.py @@ -1511,6 +1511,11 @@ class RenameConnectionRequest(BaseModel): client_id: str = '' new_name: str = '' +class SaveConnectionConfigRequest(BaseModel): + protocol: str = 'awg' + client_id: str = '' + config: str = '' + class ToggleConnectionRequest(BaseModel): protocol: str = 'awg' @@ -3124,6 +3129,31 @@ async def api_rename_connection(request: Request, server_id: int, req: RenameCon return JSONResponse({'error': str(e)}, status_code=500) +@app.post('/api/servers/{server_id}/connections/config/save', tags=["Connections"]) +async def api_save_connection_config(request: Request, server_id: int, req: SaveConnectionConfigRequest): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + config_text = (req.config or '').strip() + if not config_text: + return JSONResponse({'error': 'Config is required'}, status_code=400) + if not req.client_id: + return JSONResponse({'error': 'Client ID is required'}, status_code=400) + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, req.protocol) + _manager_call(manager, 'save_client_config', req.protocol, req.client_id, config_text) + ssh.disconnect() + return {'status': 'success', 'vpn_link': generate_vpn_link(config_text)} + except Exception as e: + logger.exception("Error saving connection config") + return JSONResponse({'error': str(e)}, status_code=500) + + @app.post('/api/servers/{server_id}/connections/config', tags=["Connections"]) async def api_get_connection_config(request: Request, server_id: int, req: ConnectionActionRequest): user = get_current_user(request) From 4abea44666d8f4e2ee8750bf8a3da676c73efb1c Mon Sep 17 00:00:00 2001 From: leonidorlov-hash Date: Wed, 5 Aug 2026 03:06:36 +0300 Subject: [PATCH 15/23] feat: editable peer config with Save button, QR/vpn-link regeneration --- templates/server.html | 41 +++++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/templates/server.html b/templates/server.html index 2d1bfc0..7eafd64 100644 --- a/templates/server.html +++ b/templates/server.html @@ -848,13 +848,16 @@
-
+
- +
@@ -2114,8 +2117,9 @@ currentConfig = result.config; currentVpnLink = result.vpn_link || ''; currentConfigName = connName.replace(/\s+/g, '_'); + currentConfigClientId = result.client_id || null; document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`; - document.getElementById('configText').textContent = result.config; + const cfgTa = document.getElementById('configText'); cfgTa.value = result.config; cfgTa.readOnly = false; document.getElementById('vpnLinkText').textContent = currentVpnLink; document.getElementById('panel-vpn').style.display = (proto === 'telemt' ? 'none' : ''); document.querySelectorAll('.config-tab')[1].style.display = (proto === 'telemt' ? 'none' : ''); @@ -2240,9 +2244,33 @@ } } + async function saveConnectionConfig() { + const proto = document.getElementById('connProtoSelect').value; + const text = document.getElementById('configText').value; + if (!text.trim()) { + showToast(_('error'), 'error'); + return; + } + try { + const result = await apiCall(`/api/servers/${SERVER_ID}/connections/config/save`, 'POST', { + protocol: proto, client_id: currentConfigClientId, config: text, + }); + currentConfig = text; + currentVpnLink = result.vpn_link || ''; + document.getElementById('vpnLinkText').textContent = currentVpnLink; + generateQR(text); + showToast(_('config_saved'), 'success'); + } catch (err) { + showToast(_('error') + ': ' + err.message, 'error'); + } + } + async function showConnectionConfig(clientId, connName, hasKey) { const proto = document.getElementById('connProtoSelect').value; currentConfigClientId = clientId; + const canEditCfg = ['awg', 'awg2', 'awg_legacy', 'wireguard'].includes(protoBase(proto)); + document.getElementById('saveConnConfigBtn').style.display = canEditCfg ? '' : 'none'; + document.getElementById('configText').readOnly = !canEditCfg; // Restore tabs visibility first document.getElementById('panel-vpn').style.display = (proto === 'telemt' ? 'none' : ''); @@ -2254,9 +2282,10 @@ if (!hasKey) { document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`; currentConfig = ''; + document.getElementById('saveConnConfigBtn').style.display = 'none'; currentVpnLink = ''; currentConfigName = connName.replace(/\s+/g, '_'); - document.getElementById('configText').innerHTML = `
⚠️
${_('config_unavailable')}
${_('config_unavailable_desc').replace(/\\n/g, '
')}

${_('client_public_key')}
${escapeHtml(clientId)}
`; + const cfgTa = document.getElementById('configText'); cfgTa.value = `${_('config_unavailable')}\n\n${_('client_public_key')}: ${clientId}`; cfgTa.readOnly = true; document.getElementById('vpnLinkText').textContent = ''; document.getElementById('panel-vpn').style.display = 'none'; document.getElementById('panel-qr').style.display = 'none'; @@ -2277,7 +2306,7 @@ currentVpnLink = result.vpn_link || ''; currentConfigName = connName.replace(/\s+/g, '_'); document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`; - document.getElementById('configText').textContent = result.config; + const cfgTa = document.getElementById('configText'); cfgTa.value = result.config; cfgTa.readOnly = false; document.getElementById('vpnLinkText').textContent = currentVpnLink; switchConfigTab('conf'); openModal('configModal'); From c13c044eb72fa63dc773d94fda2b68b98346110b Mon Sep 17 00:00:00 2001 From: leonidorlov-hash Date: Wed, 5 Aug 2026 03:06:38 +0300 Subject: [PATCH 16/23] i18n: config_saved (en) --- translations/en.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/translations/en.json b/translations/en.json index 87df516..38f8b5e 100644 --- a/translations/en.json +++ b/translations/en.json @@ -422,5 +422,6 @@ "refresh": "Refresh", "rename": "Rename", "connection_renamed": "Connection renamed", - "enter_connection_name": "Enter a new connection name" + "enter_connection_name": "Enter a new connection name", + "config_saved": "Config saved" } \ No newline at end of file From 39e0bb3d08d7a80abf5cc8eebbea2ab348e0ccf9 Mon Sep 17 00:00:00 2001 From: leonidorlov-hash Date: Wed, 5 Aug 2026 03:06:41 +0300 Subject: [PATCH 17/23] i18n: config_saved (ru) --- translations/ru.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/translations/ru.json b/translations/ru.json index fc5c419..253053d 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -422,5 +422,6 @@ "refresh": "Обновить", "rename": "Переименовать", "connection_renamed": "Подключение переименовано", - "enter_connection_name": "Введите новое имя подключения" + "enter_connection_name": "Введите новое имя подключения", + "config_saved": "Конфиг сохранён" } \ No newline at end of file From d23108f182b75bc957a79f82ef915585732c653c Mon Sep 17 00:00:00 2001 From: leonidorlov-hash Date: Wed, 5 Aug 2026 03:06:43 +0300 Subject: [PATCH 18/23] i18n: config_saved (fr) --- translations/fr.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/translations/fr.json b/translations/fr.json index b1d5811..8bcc0ab 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -389,5 +389,6 @@ "refresh": "Refresh", "rename": "Renommer", "connection_renamed": "Connexion renommée", - "enter_connection_name": "Saisir un nouveau nom de connexion" + "enter_connection_name": "Saisir un nouveau nom de connexion", + "config_saved": "Configuration enregistrée" } \ No newline at end of file From 83cd8a494154be39b9849148a0968552b4336093 Mon Sep 17 00:00:00 2001 From: leonidorlov-hash Date: Wed, 5 Aug 2026 03:06:46 +0300 Subject: [PATCH 19/23] i18n: config_saved (fa) --- translations/fa.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/translations/fa.json b/translations/fa.json index 2528645..4d6b9b1 100644 --- a/translations/fa.json +++ b/translations/fa.json @@ -389,5 +389,6 @@ "refresh": "Refresh", "rename": "تغییر نام", "connection_renamed": "اتصال تغییر نام یافت", - "enter_connection_name": "نام جدید اتصال را وارد کنید" + "enter_connection_name": "نام جدید اتصال را وارد کنید", + "config_saved": "پیکربندی ذخیره شد" } \ No newline at end of file From d5cbe49572e5a8f7b12ac4540022aa5d70ed1d8c Mon Sep 17 00:00:00 2001 From: leonidorlov-hash Date: Wed, 5 Aug 2026 03:06:48 +0300 Subject: [PATCH 20/23] i18n: config_saved (zh) --- translations/zh.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/translations/zh.json b/translations/zh.json index 938922f..66832b3 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -389,5 +389,6 @@ "refresh": "Refresh", "rename": "重命名", "connection_renamed": "连接已重命名", - "enter_connection_name": "请输入新的连接名称" + "enter_connection_name": "请输入新的连接名称", + "config_saved": "配置已保存" } \ No newline at end of file From e7373730fff246839c29fdc7d48efbc7e38dbaf9 Mon Sep 17 00:00:00 2001 From: leonidorlov-hash Date: Wed, 5 Aug 2026 17:10:07 +0300 Subject: [PATCH 21/23] fix: comment out DNS in server config on save (awg-quick/resolvconf), parse commented DNS --- managers/awg_manager.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/managers/awg_manager.py b/managers/awg_manager.py index a559392..a29016f 100644 --- a/managers/awg_manager.py +++ b/managers/awg_manager.py @@ -684,8 +684,25 @@ tail -f /dev/null raise RuntimeError(f"Failed to get server config: {err}") return out + @staticmethod + def _sanitize_server_config(config_content): + """awg-quick chokes on a bare `DNS =` key in the server config (it calls + resolvconf, which is missing in the container, and the interface goes + down). Convert active `DNS = ...` lines into `# DNS = ...` comments: + the panel still reads them via _get_dns(), but quick-tools ignore them.""" + lines = [] + for line in config_content.split('\n'): + stripped = line.strip() + if stripped.startswith('DNS') and '=' in stripped and not stripped.startswith('#'): + indent = line[:len(line) - len(line.lstrip())] + lines.append(f"{indent}# {stripped}") + else: + lines.append(line) + return '\n'.join(lines) + def save_server_config(self, protocol_type, config_content): """Save the server WireGuard config and restart container.""" + config_content = self._sanitize_server_config(config_content) container_name = self._container_name(protocol_type) config_path = self._resolve_config_path(protocol_type) @@ -1353,6 +1370,8 @@ AllowedIPs = {client_ip}/32 server_config = self._get_server_config(protocol_type) for line in server_config.split('\n'): stripped = line.strip() + if stripped.startswith('#'): + stripped = stripped.lstrip('#').strip() if stripped.startswith('DNS') and '=' in stripped: return stripped.split('=', 1)[1].strip() except Exception: From f05f9a467750b4c4acdf26fcbaaeb5a2cdaa718f Mon Sep 17 00:00:00 2001 From: leonidorlov-hash Date: Wed, 5 Aug 2026 17:10:09 +0300 Subject: [PATCH 22/23] fix: comment out DNS in server config on save (wg-quick/resolvconf), parse commented DNS --- managers/wireguard_manager.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/managers/wireguard_manager.py b/managers/wireguard_manager.py index 224a2c0..1f8e6b6 100644 --- a/managers/wireguard_manager.py +++ b/managers/wireguard_manager.py @@ -376,8 +376,24 @@ tail -f /dev/null raise RuntimeError(f"Failed to get server config: {err}") return out + @staticmethod + def _sanitize_server_config(config_content): + """wg-quick chokes on a bare `DNS =` key in the server config (it calls + resolvconf, which is missing in the container). Convert active + `DNS = ...` lines into `# DNS = ...` comments (see AWGManager).""" + lines = [] + for line in config_content.split('\n'): + stripped = line.strip() + if stripped.startswith('DNS') and '=' in stripped and not stripped.startswith('#'): + indent = line[:len(line) - len(line.lstrip())] + lines.append(f"{indent}# {stripped}") + else: + lines.append(line) + return '\n'.join(lines) + def save_server_config(self, config_content): """Save the server WireGuard config and restart container.""" + config_content = self._sanitize_server_config(config_content) self.ssh.upload_file(config_content.replace('\r\n', '\n'), "/tmp/_wg_edit_config.conf") self.ssh.run_sudo_command(f"docker cp /tmp/_wg_edit_config.conf {self.CONTAINER_NAME}:{self.CONFIG_PATH}") self.ssh.run_command("rm -f /tmp/_wg_edit_config.conf") @@ -769,6 +785,8 @@ AllowedIPs = {client_ip}/32 server_config = self._get_server_config() for line in server_config.split('\n'): stripped = line.strip() + if stripped.startswith('#'): + stripped = stripped.lstrip('#').strip() if stripped.startswith('DNS') and '=' in stripped: return stripped.split('=', 1)[1].strip() except Exception: From 0b5b0033ddc686246d3b6daee0555952234a997c Mon Sep 17 00:00:00 2001 From: leonidorlov-hash Date: Thu, 6 Aug 2026 01:37:41 +0300 Subject: [PATCH 23/23] fix(xray): make hot user add work with Xray-core v26 runtime API xray api adu in Xray-core v26 builds a full inbound config from the payload and silently adds 0 users (exit code 0) when port/listen are missing. Include the inbound's port and listen in the adu payload and treat "Added 0 user(s)" output as a failure so the caller falls back to the restart-required error path instead of reporting success. --- managers/xray_manager.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/managers/xray_manager.py b/managers/xray_manager.py index 3483fa5..048a18a 100644 --- a/managers/xray_manager.py +++ b/managers/xray_manager.py @@ -400,9 +400,12 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ] tag = self._get_vless_inbound_tag(config) if not tag: return False + _ib = self._get_vless_inbound(config) or {} payload = { "inbounds": [{ "tag": tag, + "port": _ib.get('port', 443), + "listen": "0.0.0.0", "protocol": "vless", "settings": { "clients": [client], @@ -411,7 +414,7 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ] }] } ok, message = self._run_xray_api_json('adu', payload) - if not ok: + if not ok or 'Added 0 user' in (message or ''): logger.warning(f"Xray API add user failed: {message}") return False return True