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(