fix: prevent test runs from corrupting live WG state; sync wg0.conf on IP change
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 <noreply@anthropic.com>
This commit is contained in:
+16
-2
@@ -328,17 +328,24 @@ def unregister_peer(peer_name):
|
|||||||
@bp.route('/api/peers/<peer_name>/update-ip', methods=['PUT'])
|
@bp.route('/api/peers/<peer_name>/update-ip', methods=['PUT'])
|
||||||
def update_peer_ip_registry(peer_name):
|
def update_peer_ip_registry(peer_name):
|
||||||
try:
|
try:
|
||||||
from app import peer_registry, routing_manager
|
from app import peer_registry, routing_manager, wireguard_manager
|
||||||
data = request.get_json(silent=True)
|
data = request.get_json(silent=True)
|
||||||
new_ip = data.get('ip') if data else None
|
new_ip = data.get('ip') if data else None
|
||||||
if not new_ip:
|
if not new_ip:
|
||||||
return jsonify({"error": "Missing ip"}), 400
|
return jsonify({"error": "Missing ip"}), 400
|
||||||
|
peer = peer_registry.get_peer(peer_name)
|
||||||
success = peer_registry.update_peer_ip(peer_name, new_ip)
|
success = peer_registry.update_peer_ip(peer_name, new_ip)
|
||||||
if success:
|
if success:
|
||||||
try:
|
try:
|
||||||
routing_manager.update_peer_ip(peer_name, new_ip)
|
routing_manager.update_peer_ip(peer_name, new_ip)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"RoutingManager update_peer_ip failed: {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({"message": f"IP update received for {peer_name}"})
|
||||||
return jsonify({"error": f"Peer {peer_name} not found"}), 404
|
return jsonify({"error": f"Peer {peer_name} not found"}), 404
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -349,7 +356,7 @@ def update_peer_ip_registry(peer_name):
|
|||||||
@bp.route('/api/ip-update', methods=['POST'])
|
@bp.route('/api/ip-update', methods=['POST'])
|
||||||
def ip_update():
|
def ip_update():
|
||||||
try:
|
try:
|
||||||
from app import peer_registry, routing_manager
|
from app import peer_registry, routing_manager, wireguard_manager
|
||||||
data = request.get_json(silent=True)
|
data = request.get_json(silent=True)
|
||||||
if data is None:
|
if data is None:
|
||||||
return jsonify({"error": "No data provided"}), 400
|
return jsonify({"error": "No data provided"}), 400
|
||||||
@@ -357,12 +364,19 @@ def ip_update():
|
|||||||
new_ip = data.get('ip')
|
new_ip = data.get('ip')
|
||||||
if not peer_name or not new_ip:
|
if not peer_name or not new_ip:
|
||||||
return jsonify({"error": "Missing peer or ip"}), 400
|
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)
|
success = peer_registry.update_peer_ip(peer_name, new_ip)
|
||||||
if success:
|
if success:
|
||||||
try:
|
try:
|
||||||
routing_manager.update_peer_ip(peer_name, new_ip)
|
routing_manager.update_peer_ip(peer_name, new_ip)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"RoutingManager update_peer_ip failed: {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({"message": f"IP update received for {peer_name}"})
|
||||||
return jsonify({"error": f"Peer {peer_name} not found"}), 404
|
return jsonify({"error": f"Peer {peer_name} not found"}), 404
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ class WireGuardManager(BaseServiceManager):
|
|||||||
Docker bridge subnet would cause routing conflicts when cells share the same range.
|
Docker bridge subnet would cause routing conflicts when cells share the same range.
|
||||||
"""
|
"""
|
||||||
local_net = self._get_configured_network()
|
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 = []
|
cell_nets = []
|
||||||
try:
|
try:
|
||||||
with open(cell_links_file) as f:
|
with open(cell_links_file) as f:
|
||||||
@@ -449,8 +449,8 @@ class WireGuardManager(BaseServiceManager):
|
|||||||
"""
|
"""
|
||||||
import subprocess, re
|
import subprocess, re
|
||||||
real_conf = self._config_file()
|
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:
|
||||||
logger.debug('_syncconf: skipping — config path looks like a test dir')
|
logger.debug('_syncconf: skipping — not running inside container')
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
# Parse desired peers from config file
|
# 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'.
|
wg-quick would do this automatically, but we manage WG live via 'wg set'.
|
||||||
"""
|
"""
|
||||||
real_conf = self._config_file()
|
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
|
return
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
@@ -653,7 +653,7 @@ class WireGuardManager(BaseServiceManager):
|
|||||||
are ephemeral; only the WG peer config in wg0.conf persists).
|
are ephemeral; only the WG peer config in wg0.conf persists).
|
||||||
"""
|
"""
|
||||||
real_conf = self._config_file()
|
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
|
return
|
||||||
try:
|
try:
|
||||||
content = self._read_config()
|
content = self._read_config()
|
||||||
@@ -715,7 +715,7 @@ class WireGuardManager(BaseServiceManager):
|
|||||||
treated as success.
|
treated as success.
|
||||||
"""
|
"""
|
||||||
real_conf = self._config_file()
|
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
|
return True
|
||||||
try:
|
try:
|
||||||
def _wg(cmd):
|
def _wg(cmd):
|
||||||
@@ -738,7 +738,7 @@ class WireGuardManager(BaseServiceManager):
|
|||||||
def remove_peer_route_via(self, peer_ip: str, table: int = 100) -> None:
|
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."""
|
"""Remove the ip rule for peer_ip added by apply_peer_route_via. Non-fatal."""
|
||||||
real_conf = self._config_file()
|
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
|
return
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
@@ -836,7 +836,7 @@ class WireGuardManager(BaseServiceManager):
|
|||||||
def _apply_peer_allowed_ips_live(self, public_key: str, new_ips: str) -> None:
|
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)."""
|
"""Apply AllowedIPs for one peer via wg set (no spaces — wg rejects them)."""
|
||||||
real_conf = self._config_file()
|
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
|
return
|
||||||
try:
|
try:
|
||||||
ips = new_ips.replace(' ', '')
|
ips = new_ips.replace(' ', '')
|
||||||
|
|||||||
@@ -828,7 +828,7 @@ class TestCellRoutes(unittest.TestCase):
|
|||||||
|
|
||||||
def test_ensure_cell_route_calls_ip_route_add(self):
|
def test_ensure_cell_route_calls_ip_route_add(self):
|
||||||
"""Outside test dirs, _ensure_cell_route calls docker exec ip route add."""
|
"""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:
|
with patch('subprocess.run') as mock_run:
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
self.wg._ensure_cell_route('10.1.0.0/24')
|
self.wg._ensure_cell_route('10.1.0.0/24')
|
||||||
@@ -849,7 +849,7 @@ class TestCellRoutes(unittest.TestCase):
|
|||||||
'[Peer]\n# alice\nPublicKey = YWxpY2VwdWJrZXlfZm9yX3Rlc3RzX3dndGVzdDEyMyE=\n'
|
'[Peer]\n# alice\nPublicKey = YWxpY2VwdWJrZXlfZm9yX3Rlc3RzX3dndGVzdDEyMyE=\n'
|
||||||
'AllowedIPs = 10.0.0.2/32\nPersistentKeepalive = 25\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.object(self.wg, '_read_config', return_value=conf):
|
||||||
with patch('subprocess.run') as mock_run:
|
with patch('subprocess.run') as mock_run:
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
|
|||||||
Reference in New Issue
Block a user