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 <noreply@anthropic.com>
This commit is contained in:
@@ -98,6 +98,27 @@ class CellLinkManager:
|
|||||||
keys = self.wireguard_manager.get_keys()
|
keys = self.wireguard_manager.get_keys()
|
||||||
return {'cell_name': cell_name, 'public_key': keys.get('public_key', '')}
|
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],
|
def _push_permissions_to_remote(self, link: Dict[str, Any],
|
||||||
from_cell: str,
|
from_cell: str,
|
||||||
from_public_key: str) -> Dict[str, Any]:
|
from_public_key: str) -> Dict[str, Any]:
|
||||||
@@ -130,11 +151,22 @@ class CellLinkManager:
|
|||||||
payload = json.dumps(body)
|
payload = json.dumps(body)
|
||||||
endpoint = url.rstrip('/') + '/api/cells/peer-sync/permissions'
|
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 = [
|
cmd = [
|
||||||
'docker', 'exec', 'cell-wireguard',
|
'docker', 'exec', 'cell-wireguard',
|
||||||
'curl', '-s', '-o', '/dev/null', '-w', '%{http_code}',
|
'curl', '-s', '-o', '/dev/null', '-w', '%{http_code}',
|
||||||
'-X', 'POST',
|
'-X', 'POST',
|
||||||
'-H', 'Content-Type: application/json',
|
'-H', 'Content-Type: application/json',
|
||||||
|
]
|
||||||
|
if xff_header:
|
||||||
|
cmd += ['-H', xff_header]
|
||||||
|
cmd += [
|
||||||
'-d', payload,
|
'-d', payload,
|
||||||
'--max-time', str(_PUSH_TIMEOUT),
|
'--max-time', str(_PUSH_TIMEOUT),
|
||||||
'--connect-timeout', '3',
|
'--connect-timeout', '3',
|
||||||
|
|||||||
@@ -524,7 +524,12 @@ class TestPermissionSync(unittest.TestCase):
|
|||||||
|
|
||||||
def fake_run(cmd, **kwargs):
|
def fake_run(cmd, **kwargs):
|
||||||
import json as _j
|
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')
|
d_idx = cmd.index('-d')
|
||||||
sent_body.update(_j.loads(cmd[d_idx + 1]))
|
sent_body.update(_j.loads(cmd[d_idx + 1]))
|
||||||
r = MagicMock()
|
r = MagicMock()
|
||||||
@@ -543,6 +548,41 @@ class TestPermissionSync(unittest.TestCase):
|
|||||||
# Our outbound=files:True → their inbound=files:True
|
# Our outbound=files:True → their inbound=files:True
|
||||||
self.assertTrue(pushed_perms['inbound']['files'])
|
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):
|
def test_push_http_error_returns_not_ok(self):
|
||||||
self._add_office()
|
self._add_office()
|
||||||
link = self.mgr.list_connections()[0]
|
link = self.mgr.list_connections()[0]
|
||||||
@@ -550,7 +590,16 @@ class TestPermissionSync(unittest.TestCase):
|
|||||||
mock_result.returncode = 0
|
mock_result.returncode = 0
|
||||||
mock_result.stdout = '503'
|
mock_result.stdout = '503'
|
||||||
mock_result.stderr = ''
|
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=')
|
result = self.mgr._push_permissions_to_remote(link, 'home', 'homepubkey=')
|
||||||
self.assertFalse(result['ok'])
|
self.assertFalse(result['ok'])
|
||||||
self.assertIn('503', result['error'])
|
self.assertIn('503', result['error'])
|
||||||
|
|||||||
Reference in New Issue
Block a user