diff --git a/app.py b/app.py index 013e76b..5d9a4f2 100644 --- a/app.py +++ b/app.py @@ -1506,6 +1506,17 @@ class ConnectionActionRequest(BaseModel): client_id: str = '' +class RenameConnectionRequest(BaseModel): + protocol: str = 'awg' + client_id: str = '' + new_name: str = '' + +class SaveConnectionConfigRequest(BaseModel): + protocol: str = 'awg' + client_id: str = '' + config: str = '' + + class ToggleConnectionRequest(BaseModel): protocol: str = 'awg' client_id: str = '' @@ -3082,6 +3093,67 @@ 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/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) diff --git a/managers/awg_manager.py b/managers/awg_manager.py index 51710d2..f42a69c 100644 --- a/managers/awg_manager.py +++ b/managers/awg_manager.py @@ -757,8 +757,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) @@ -900,28 +917,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.""" @@ -1104,11 +1171,8 @@ PresharedKey = {psk} AllowedIPs = {allowed_ips} """ - # 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( @@ -1138,22 +1202,16 @@ AllowedIPs = {allowed_ips} 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 (dual-stack when the client has an IPv6 address) address_line = f"{client_ip}/32" + (f", {client_ipv6}/128" if client_ipv6 else "") dns_line = f"{dns1}, {dns2}" + (", 2606:4700:4700::1111" if client_ipv6 else "") config_lines = [ - f"Address = {address_line}", - f"DNS = {dns_line}", + f"Address = {client_ip}/32", + f"DNS = {dns}", f"PrivateKey = {client_priv_key}", f"MTU = {mtu}" ] @@ -1217,6 +1275,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', '') @@ -1234,22 +1294,16 @@ 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 (dual-stack when the client has an IPv6 address) address_line = f"{client_ip}/32" + (f", {client_ipv6}/128" if client_ipv6 else "") dns_line = f"{dns1}, {dns2}" + (", 2606:4700:4700::1111" if client_ipv6 else "") config_lines = [ - f"Address = {address_line}", - f"DNS = {dns_line}", + f"Address = {client_ip}/32", + f"DNS = {dns}", f"PrivateKey = {client_priv_key}", f"MTU = {mtu}" ] @@ -1453,6 +1507,82 @@ AllowedIPs = {allowed_ips} 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('#'): + stripped = stripped.lstrip('#').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 + 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) @@ -1479,4 +1609,4 @@ AllowedIPs = {allowed_ips} except Exception as e: info['error'] = str(e) - return info \ No newline at end of file + return info 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" diff --git a/managers/wireguard_manager.py b/managers/wireguard_manager.py index b646cd3..a9b4fdd 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") @@ -426,22 +442,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.""" @@ -583,17 +652,10 @@ 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 peer_section = f""" [Peer] PublicKey = {client_pub_key} @@ -601,10 +663,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( @@ -630,7 +690,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 +716,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 +731,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 +823,78 @@ 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('#'): + stripped = stripped.lstrip('#').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 + 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..2b52c50 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 @@ -798,3 +801,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} diff --git a/templates/server.html b/templates/server.html index 4a06506..7eafd64 100644 --- a/templates/server.html +++ b/templates/server.html @@ -848,13 +848,16 @@
-
+
- +
@@ -950,6 +953,7 @@ let currentConfig = ''; let currentVpnLink = ''; let currentConfigName = 'connection'; + let currentConfigClientId = null; let installedProtocols = {}; let currentProtocolStatus = {}; let installedAppsLoading = true; @@ -2063,8 +2067,9 @@
+ - ${proto === 'telemt' ? `` : ''} + ${proto === 'telemt' ? `` : ''}
@@ -2112,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' : ''); @@ -2190,8 +2196,81 @@ } } + 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 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' : ''); @@ -2203,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'; @@ -2226,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'); diff --git a/translations/en.json b/translations/en.json index cf10d58..38f8b5e 100644 --- a/translations/en.json +++ b/translations/en.json @@ -419,5 +419,9 @@ "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", + "config_saved": "Config saved" +} \ No newline at end of file diff --git a/translations/fa.json b/translations/fa.json index fd7096c..4d6b9b1 100644 --- a/translations/fa.json +++ b/translations/fa.json @@ -386,5 +386,9 @@ "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": "نام جدید اتصال را وارد کنید", + "config_saved": "پیکربندی ذخیره شد" +} \ No newline at end of file diff --git a/translations/fr.json b/translations/fr.json index 4f4bdc3..8bcc0ab 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -386,5 +386,9 @@ "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", + "config_saved": "Configuration enregistrée" +} \ No newline at end of file diff --git a/translations/ru.json b/translations/ru.json index e55b9b5..253053d 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -419,5 +419,9 @@ "backup_created": "Бекап создан", "no_backups": "Бекапов пока нет", "no_backups_desc": "Создайте первый бекап для этого протокола.", - "refresh": "Обновить" -} + "refresh": "Обновить", + "rename": "Переименовать", + "connection_renamed": "Подключение переименовано", + "enter_connection_name": "Введите новое имя подключения", + "config_saved": "Конфиг сохранён" +} \ No newline at end of file diff --git a/translations/zh.json b/translations/zh.json index a611051..66832b3 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -386,5 +386,9 @@ "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": "请输入新的连接名称", + "config_saved": "配置已保存" +} \ No newline at end of file