From c4181abb2af48cf05a71f051cafe23d008ecb197 Mon Sep 17 00:00:00 2001 From: leonidorlov-hash Date: Sat, 1 Aug 2026 16:19:15 +0300 Subject: [PATCH] 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 | 97 ++++++++++++++++++++++++++--------- managers/wireguard_manager.py | 90 ++++++++++++++++++++++++-------- 2 files changed, 142 insertions(+), 45 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 @@ def _get_used_ips(self, protocol_type): 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) - - # Get the last used IP and increment - last_ip = used_ips[-1] - parts = last_ip.split('.') - last_octet = int(parts[3]) - - if last_octet == 254: - next_octet = last_octet + 3 - elif last_octet == 255: - next_octet = last_octet + 2 - else: - next_octet = last_octet + 1 + base = self._get_subnet_base(protocol_type) + 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 - parts[3] = str(next_octet) - return '.'.join(parts) + 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, 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 @@ def add_client(self, protocol_type, client_name, server_host, port): 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 @@ def _get_used_ips(self): return ips def _get_next_ip(self): - """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() - if not used_ips: - base = WG_DEFAULTS['subnet_address'] - parts = base.split('.') - parts[3] = '2' - return '.'.join(parts) - - 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) + 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 @@ def add_client(self, client_name, server_host): mtu = WG_DEFAULTS['mtu'] - # Append peer to server config peer_section = f""" [Peer] PublicKey = {client_pub_key} @@ -601,10 +653,8 @@ def add_client(self, client_name, server_host): 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(