From 0e16d6968aac6bfc10e1124078e51586f22a8296 Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Sat, 2 May 2026 07:45:28 -0400 Subject: [PATCH] fix: prevent test runs from corrupting live WG state; sync wg0.conf on IP change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes: 1. Extend the docker-exec safety guard in wireguard_manager to also check for 'wg_confs' in the config path. When running unit tests on the host the API uses /app/config/wireguard/wg0.conf (no wg_confs subdir), so the old '/tmp/' | 'pytest' check didn't fire — _syncconf and friends were executing live 'docker exec cell-wireguard wg set' calls against the running container, removing real VPN peers that didn't appear in the test config. The wg_confs subdir only exists inside the container mount, so its presence reliably gates live calls. 2. Fix get_split_tunnel_ips() wrong path: self.data_dir + 'api/cell_links.json' → self.data_dir + 'cell_links.json'. The extra 'api/' segment produced /app/data/api/cell_links.json inside the container instead of the real /app/data/cell_links.json, so connected cells were silently excluded from split-tunnel CIDRs. 3. update_peer_ip_registry and ip_update now also call wireguard_manager.update_peer_ip so wg0.conf AllowedIPs stay in sync when a peer's VPN IP changes at runtime (previously only peers.json was updated). Co-Authored-By: Claude Sonnet 4.6 --- api/routes/peers.py | 18 ++++++++++++++++-- api/wireguard_manager.py | 16 ++++++++-------- tests/test_wireguard_manager.py | 4 ++-- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/api/routes/peers.py b/api/routes/peers.py index 253c5b4..7c011ee 100644 --- a/api/routes/peers.py +++ b/api/routes/peers.py @@ -328,17 +328,24 @@ def unregister_peer(peer_name): @bp.route('/api/peers//update-ip', methods=['PUT']) def update_peer_ip_registry(peer_name): try: - from app import peer_registry, routing_manager + from app import peer_registry, routing_manager, wireguard_manager data = request.get_json(silent=True) new_ip = data.get('ip') if data else None if not new_ip: return jsonify({"error": "Missing ip"}), 400 + peer = peer_registry.get_peer(peer_name) success = peer_registry.update_peer_ip(peer_name, new_ip) if success: try: routing_manager.update_peer_ip(peer_name, new_ip) except Exception as e: logger.warning(f"RoutingManager update_peer_ip failed: {e}") + if peer and peer.get('public_key'): + try: + wg_ip = new_ip if '/' in new_ip else f'{new_ip}/32' + wireguard_manager.update_peer_ip(peer['public_key'], wg_ip) + except Exception as e: + logger.warning(f"WireGuard update_peer_ip failed: {e}") return jsonify({"message": f"IP update received for {peer_name}"}) return jsonify({"error": f"Peer {peer_name} not found"}), 404 except Exception as e: @@ -349,7 +356,7 @@ def update_peer_ip_registry(peer_name): @bp.route('/api/ip-update', methods=['POST']) def ip_update(): try: - from app import peer_registry, routing_manager + from app import peer_registry, routing_manager, wireguard_manager data = request.get_json(silent=True) if data is None: return jsonify({"error": "No data provided"}), 400 @@ -357,12 +364,19 @@ def ip_update(): new_ip = data.get('ip') if not peer_name or not new_ip: return jsonify({"error": "Missing peer or ip"}), 400 + peer = peer_registry.get_peer(peer_name) success = peer_registry.update_peer_ip(peer_name, new_ip) if success: try: routing_manager.update_peer_ip(peer_name, new_ip) except Exception as e: logger.warning(f"RoutingManager update_peer_ip failed: {e}") + if peer and peer.get('public_key'): + try: + wg_ip = new_ip if '/' in new_ip else f'{new_ip}/32' + wireguard_manager.update_peer_ip(peer['public_key'], wg_ip) + except Exception as e: + logger.warning(f"WireGuard update_peer_ip failed: {e}") return jsonify({"message": f"IP update received for {peer_name}"}) return jsonify({"error": f"Peer {peer_name} not found"}), 404 except Exception as e: diff --git a/api/wireguard_manager.py b/api/wireguard_manager.py index dc48d99..9cdb969 100644 --- a/api/wireguard_manager.py +++ b/api/wireguard_manager.py @@ -296,7 +296,7 @@ class WireGuardManager(BaseServiceManager): Docker bridge subnet would cause routing conflicts when cells share the same range. """ local_net = self._get_configured_network() - cell_links_file = os.path.join(self.data_dir, 'api', 'cell_links.json') + cell_links_file = os.path.join(self.data_dir, 'cell_links.json') cell_nets = [] try: with open(cell_links_file) as f: @@ -449,8 +449,8 @@ class WireGuardManager(BaseServiceManager): """ import subprocess, re real_conf = self._config_file() - if '/tmp/' in real_conf or 'pytest' in real_conf: - logger.debug('_syncconf: skipping — config path looks like a test dir') + if '/tmp/' in real_conf or 'pytest' in real_conf or 'wg_confs' not in real_conf: + logger.debug('_syncconf: skipping — not running inside container') return try: # Parse desired peers from config file @@ -634,7 +634,7 @@ class WireGuardManager(BaseServiceManager): wg-quick would do this automatically, but we manage WG live via 'wg set'. """ real_conf = self._config_file() - if '/tmp/' in real_conf or 'pytest' in real_conf: + if '/tmp/' in real_conf or 'pytest' in real_conf or 'wg_confs' not in real_conf: return try: subprocess.run( @@ -653,7 +653,7 @@ class WireGuardManager(BaseServiceManager): are ephemeral; only the WG peer config in wg0.conf persists). """ real_conf = self._config_file() - if '/tmp/' in real_conf or 'pytest' in real_conf: + if '/tmp/' in real_conf or 'pytest' in real_conf or 'wg_confs' not in real_conf: return try: content = self._read_config() @@ -715,7 +715,7 @@ class WireGuardManager(BaseServiceManager): treated as success. """ real_conf = self._config_file() - if '/tmp/' in real_conf or 'pytest' in real_conf: + if '/tmp/' in real_conf or 'pytest' in real_conf or 'wg_confs' not in real_conf: return True try: def _wg(cmd): @@ -738,7 +738,7 @@ class WireGuardManager(BaseServiceManager): def remove_peer_route_via(self, peer_ip: str, table: int = 100) -> None: """Remove the ip rule for peer_ip added by apply_peer_route_via. Non-fatal.""" real_conf = self._config_file() - if '/tmp/' in real_conf or 'pytest' in real_conf: + if '/tmp/' in real_conf or 'pytest' in real_conf or 'wg_confs' not in real_conf: return try: subprocess.run( @@ -836,7 +836,7 @@ class WireGuardManager(BaseServiceManager): def _apply_peer_allowed_ips_live(self, public_key: str, new_ips: str) -> None: """Apply AllowedIPs for one peer via wg set (no spaces — wg rejects them).""" real_conf = self._config_file() - if '/tmp/' in real_conf or 'pytest' in real_conf: + if '/tmp/' in real_conf or 'pytest' in real_conf or 'wg_confs' not in real_conf: return try: ips = new_ips.replace(' ', '') diff --git a/tests/test_wireguard_manager.py b/tests/test_wireguard_manager.py index ce8e317..0d5272d 100644 --- a/tests/test_wireguard_manager.py +++ b/tests/test_wireguard_manager.py @@ -828,7 +828,7 @@ class TestCellRoutes(unittest.TestCase): def test_ensure_cell_route_calls_ip_route_add(self): """Outside test dirs, _ensure_cell_route calls docker exec ip route add.""" - with patch.object(self.wg, '_config_file', return_value='/app/config/wireguard/wg0.conf'): + with patch.object(self.wg, '_config_file', return_value='/app/config/wireguard/wg_confs/wg0.conf'): with patch('subprocess.run') as mock_run: mock_run.return_value = MagicMock(returncode=0) self.wg._ensure_cell_route('10.1.0.0/24') @@ -849,7 +849,7 @@ class TestCellRoutes(unittest.TestCase): '[Peer]\n# alice\nPublicKey = YWxpY2VwdWJrZXlfZm9yX3Rlc3RzX3dndGVzdDEyMyE=\n' 'AllowedIPs = 10.0.0.2/32\nPersistentKeepalive = 25\n' ) - with patch.object(self.wg, '_config_file', return_value='/app/config/wireguard/wg0.conf'): + with patch.object(self.wg, '_config_file', return_value='/app/config/wireguard/wg_confs/wg0.conf'): with patch.object(self.wg, '_read_config', return_value=conf): with patch('subprocess.run') as mock_run: mock_run.return_value = MagicMock(returncode=0)