From 7da0cbb714688763674f15d79bc7af83d40e3a81 Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Fri, 1 May 2026 15:24:08 -0400 Subject: [PATCH] fix: add X-Forwarded-For WG IP to peer-sync push curl command MASQUERADE rewrites the source IP of forwarded packets from the cell's WG address (10.0.x.1) to cell-wireguard's bridge IP (172.20.x.9). The peer-sync endpoint authenticates callers by checking that the source IP is inside a known cell's vpn_subnet, so MASQUERADE caused all pushes to fail with 403. Fix: _push_permissions_to_remote() now calls _local_wg_ip() to get the local wg0 address and passes it as X-Forwarded-For. _authenticate_peer_cell() already supports XFF for exactly this proxying scenario. Also adds a test verifying the header is present in the constructed curl command. Co-Authored-By: Claude Sonnet 4.6 --- api/cell_link_manager.py | 32 ++++++++++++++++++++ tests/test_cell_link_manager.py | 53 +++++++++++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/api/cell_link_manager.py b/api/cell_link_manager.py index 96cf60a..91344bb 100644 --- a/api/cell_link_manager.py +++ b/api/cell_link_manager.py @@ -98,6 +98,27 @@ class CellLinkManager: keys = self.wireguard_manager.get_keys() return {'cell_name': cell_name, 'public_key': keys.get('public_key', '')} + def _local_wg_ip(self) -> Optional[str]: + """Return the local cell-wireguard wg0 IP (e.g. '10.0.0.1'). + + Used to set X-Forwarded-For so the remote peer-sync endpoint can + authenticate us by VPN subnet even after MASQUERADE changes the + apparent source to the Docker bridge IP. + """ + try: + r = subprocess.run( + ['docker', 'exec', 'cell-wireguard', + 'ip', 'addr', 'show', 'wg0'], + capture_output=True, text=True, timeout=5 + ) + for line in r.stdout.splitlines(): + line = line.strip() + if line.startswith('inet '): + return line.split()[1].split('/')[0] + except Exception: + pass + return None + def _push_permissions_to_remote(self, link: Dict[str, Any], from_cell: str, from_public_key: str) -> Dict[str, Any]: @@ -130,11 +151,22 @@ class CellLinkManager: payload = json.dumps(body) endpoint = url.rstrip('/') + '/api/cells/peer-sync/permissions' + # Determine local WG IP so the remote can authenticate us by source subnet. + # MASQUERADE rewrites source to cell-wireguard's eth0 IP (172.20.x.x), which + # is NOT in the cell's vpn_subnet. Passing the true WG IP in X-Forwarded-For + # lets _authenticate_peer_cell() find the matching cell link. + local_wg_ip = self._local_wg_ip() + xff_header = f'X-Forwarded-For: {local_wg_ip}' if local_wg_ip else None + cmd = [ 'docker', 'exec', 'cell-wireguard', 'curl', '-s', '-o', '/dev/null', '-w', '%{http_code}', '-X', 'POST', '-H', 'Content-Type: application/json', + ] + if xff_header: + cmd += ['-H', xff_header] + cmd += [ '-d', payload, '--max-time', str(_PUSH_TIMEOUT), '--connect-timeout', '3', diff --git a/tests/test_cell_link_manager.py b/tests/test_cell_link_manager.py index 0e65b85..5098904 100644 --- a/tests/test_cell_link_manager.py +++ b/tests/test_cell_link_manager.py @@ -524,7 +524,12 @@ class TestPermissionSync(unittest.TestCase): def fake_run(cmd, **kwargs): import json as _j - # Extract the -d payload from curl args + if '-d' not in cmd: + # ip addr show wg0 — return a fake wg0 address + r = MagicMock() + r.returncode = 0 + r.stdout = 'inet 10.0.0.1/24 scope global wg0\n' + return r d_idx = cmd.index('-d') sent_body.update(_j.loads(cmd[d_idx + 1])) r = MagicMock() @@ -543,6 +548,41 @@ class TestPermissionSync(unittest.TestCase): # Our outbound=files:True → their inbound=files:True self.assertTrue(pushed_perms['inbound']['files']) + def test_push_xff_header_carries_local_wg_ip(self): + """Curl command must include X-Forwarded-For with local WG IP. + + MASQUERADE rewrites source to Docker bridge IP. Without XFF the remote + can't match the sender's VPN subnet and returns 403. + """ + self._add_office() + link = self.mgr.list_connections()[0] + + captured_cmd = {} + + def fake_run(cmd, **kwargs): + if '-d' not in cmd: + r = MagicMock() + r.returncode = 0 + r.stdout = ' inet 10.0.0.1/24 scope global wg0\n' + return r + captured_cmd['cmd'] = cmd + r = MagicMock() + r.returncode = 0 + r.stdout = '200' + r.stderr = '' + return r + + with patch('subprocess.run', fake_run): + self.mgr._push_permissions_to_remote(link, 'home', 'homepubkey=') + + cmd = captured_cmd.get('cmd', []) + # X-Forwarded-For header must be in the curl command + self.assertIn('-H', cmd) + xff_idx = [i for i, x in enumerate(cmd) if x == '-H' and i + 1 < len(cmd) and 'X-Forwarded-For' in cmd[i + 1]] + self.assertTrue(xff_idx, 'X-Forwarded-For header missing from curl command') + xff_val = cmd[xff_idx[0] + 1] + self.assertIn('10.0.0.1', xff_val) + def test_push_http_error_returns_not_ok(self): self._add_office() link = self.mgr.list_connections()[0] @@ -550,7 +590,16 @@ class TestPermissionSync(unittest.TestCase): mock_result.returncode = 0 mock_result.stdout = '503' mock_result.stderr = '' - with patch('subprocess.run', return_value=mock_result): + + def fake_run(cmd, **kwargs): + if '-d' not in cmd: + r = MagicMock() + r.returncode = 0 + r.stdout = ' inet 10.0.0.1/24 scope global wg0\n' + return r + return mock_result + + with patch('subprocess.run', fake_run): result = self.mgr._push_permissions_to_remote(link, 'home', 'homepubkey=') self.assertFalse(result['ok']) self.assertIn('503', result['error'])