fix: advertise WireGuard endpoint by domain, and reach linked cells over HTTPS
Unit Tests / test (push) Successful in 9m50s

Three related cell-link/peer-config fixes (the peer and cell endpoints were
showing the raw external IP, which confused public-vs-internal addressing):

1. Peer WireGuard configs now embed the cell's effective domain (DDNS/ACME
   modes) instead of the detected external IP, via the new
   WireGuardManager.get_advertised_endpoint(). A name that resolves to the
   public IP survives IP changes and lets the datacenter forward each cell's
   WG port to the right host. LAN mode still falls back to the IP; an admin
   wireguard_endpoint override still wins.

2. Cell invites advertise <effective-domain>:<this cell's WG port> (was the
   external IP + a default/possibly-wrong port), so a remote cell pairs to the
   right host and port over the public path.

3. Cross-cell peer-sync no longer targets http://<ip>:3000 (the API binds
   127.0.0.1 and is unreachable across cells). It targets the remote's Caddy on
   HTTPS/443 — which the WireGuard server already DNATs over the tunnel — and the
   initial pre-tunnel invite push goes to https://<endpoint-host>/... ; legacy
   http://<ip>:3000 link URLs migrate to https on load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 04:21:16 -04:00
parent fa746a3b30
commit 1bb8a5eb59
6 changed files with 181 additions and 37 deletions
+40 -11
View File
@@ -26,6 +26,7 @@ def _make_wg_mock():
}
wg._get_configured_network.return_value = '10.0.0.0/24'
wg._get_configured_address.return_value = '10.0.0.1/24'
wg.get_advertised_endpoint.return_value = '1.2.3.4:51820'
wg.add_cell_peer.return_value = True
wg.remove_peer.return_value = True
return wg
@@ -82,6 +83,13 @@ class TestCellLinkManagerInvite(unittest.TestCase):
self.assertEqual(invite['cell_name'], 'myhome')
self.assertEqual(invite['domain'], 'myhome.local')
def test_generate_invite_endpoint_from_advertised_endpoint(self):
"""The invite endpoint comes from get_advertised_endpoint (domain-aware),
not a raw external IP — so the remote cell reaches us by name + our port."""
self.wg.get_advertised_endpoint.return_value = 'myhome.pic.ngo:51821'
invite = self.mgr.generate_invite('myhome', 'myhome.pic.ngo')
self.assertEqual(invite['endpoint'], 'myhome.pic.ngo:51821')
class TestCellLinkManagerConnections(unittest.TestCase):
@@ -182,7 +190,7 @@ class TestCellLinkManagerConnections(unittest.TestCase):
result = self.mgr.accept_invite(updated_invite)
self.assertEqual(result['dns_ip'], '10.1.0.2')
self.assertEqual(result['remote_api_url'], 'http://10.1.0.2:3000')
self.assertEqual(result['remote_api_url'], 'https://10.1.0.2')
self.nm.remove_cell_dns_forward.assert_called()
self.nm.add_cell_dns_forward.assert_called_with(
domain='office.cell', dns_ip='10.1.0.2')
@@ -470,9 +478,10 @@ class TestPushInviteToRemote(unittest.TestCase):
result = self.mgr._push_invite_to_remote(link)
self.assertFalse(result['ok'])
def test_push_invite_sends_to_correct_lan_host(self):
"""The curl URL must use the LAN IP from the endpoint, not the WG dns_ip."""
link = self._make_link(endpoint='192.168.31.52:51820')
def test_push_invite_sends_to_endpoint_host_over_https(self):
"""The curl targets the endpoint host on Caddy/HTTPS (443), not the WG
dns_ip and not the internal :3000 API port."""
link = self._make_link(endpoint='alice.pic.ngo:51821')
captured = {}
def fake_run(cmd, **kw):
@@ -493,10 +502,11 @@ class TestPushInviteToRemote(unittest.TestCase):
self.mgr._push_invite_to_remote(link)
url_in_cmd = captured['cmd'][-1]
self.assertIn('192.168.31.52', url_in_cmd)
self.assertIn('accept-invite', url_in_cmd)
# Must NOT use the WG dns_ip (10.1.0.1)
self.assertNotIn('10.1.0.1', url_in_cmd)
self.assertEqual(url_in_cmd,
'https://alice.pic.ngo/api/cells/peer-sync/accept-invite')
self.assertNotIn(':3000', url_in_cmd)
self.assertNotIn('10.1.0.1', url_in_cmd) # not the WG dns_ip
self.assertIn('-k', captured['cmd']) # cert may not match a bare IP
# ---------------------------------------------------------------------------
@@ -605,7 +615,7 @@ class TestAcceptInviteNew(unittest.TestCase):
with patch('firewall_manager.apply_cell_rules'):
result = self.mgr.accept_invite(updated)
self.assertEqual(result['dns_ip'], '10.1.0.5')
self.assertEqual(result['remote_api_url'], 'http://10.1.0.5:3000')
self.assertEqual(result['remote_api_url'], 'https://10.1.0.5')
self.nm.remove_cell_dns_forward.assert_called()
self.nm.add_cell_dns_forward.assert_called_with(
domain='office.cell', dns_ip='10.1.0.5')
@@ -1047,7 +1057,8 @@ class TestPermissionSync(unittest.TestCase):
def test_add_connection_sets_remote_api_url_from_dns_ip(self):
link = self._add_office()
self.assertEqual(link['remote_api_url'], 'http://10.1.0.1:3000')
# Cross-cell API is reached over the tunnel via Caddy/443, not :3000.
self.assertEqual(link['remote_api_url'], 'https://10.1.0.1')
def test_add_connection_triggers_push(self):
push_mock = MagicMock(return_value={'ok': True, 'error': None})
@@ -1321,7 +1332,7 @@ class TestPermissionSync(unittest.TestCase):
self.assertIn('last_push_status', link)
self.assertIn('last_push_at', link)
self.assertIn('last_remote_update_at', link)
self.assertEqual(link['remote_api_url'], 'http://10.1.0.1:3000')
self.assertEqual(link['remote_api_url'], 'https://10.1.0.1')
self.assertTrue(link['pending_push']) # pre-existing → marked pending
self.assertEqual(link['last_push_status'], 'never')
@@ -1330,6 +1341,24 @@ class TestPermissionSync(unittest.TestCase):
raw = json.load(f)
self.assertIn('pending_push', raw[0])
def test_load_migrates_legacy_http_3000_url_to_https(self):
"""An existing link with the old http://<ip>:3000 URL (unreachable across
cells) is rewritten to the HTTPS/Caddy form on load."""
legacy = [{
'cell_name': 'office',
'public_key': 'officepubkey=',
'vpn_subnet': '10.1.0.0/24',
'dns_ip': '10.1.0.9',
'domain': 'office.cell',
'permissions': {'inbound': {}, 'outbound': {}},
'remote_api_url': 'http://10.1.0.9:3000',
}]
links_file = os.path.join(self.test_dir, 'cell_links.json')
with open(links_file, 'w') as f:
json.dump(legacy, f)
link = self.mgr.list_connections()[0]
self.assertEqual(link['remote_api_url'], 'https://10.1.0.9')
class TestExitOffer(unittest.TestCase):
"""Tests for Phase 2: exit-offer signaling."""
+2
View File
@@ -90,11 +90,13 @@ class TestWireGuardEndpoints(unittest.TestCase):
'endpoint': '1.2.3.4:51820',
'port': 51820,
}
mock_wg.get_advertised_endpoint.return_value = '1.2.3.4:51820'
r = self.client.get('/api/wireguard/server-config')
self.assertEqual(r.status_code, 200)
data = json.loads(r.data)
self.assertIn('public_key', data)
self.assertIn('endpoint', data)
self.assertEqual(data.get('effective_endpoint'), '1.2.3.4:51820')
@patch('app.wireguard_manager')
def test_server_config_returns_500_on_exception(self, mock_wg):
+55
View File
@@ -885,5 +885,60 @@ class TestCellRoutes(unittest.TestCase):
mock_route.assert_called_once_with('10.1.0.0/24')
class _FakeCM:
"""Minimal config_manager stand-in for get_advertised_endpoint tests."""
def __init__(self, identity, effective_domain):
self._identity = identity
self._effective = effective_domain
def get_identity(self):
return self._identity
def get_effective_domain(self):
return self._effective
class TestAdvertisedEndpoint(unittest.TestCase):
"""get_advertised_endpoint prefers domain/override over the raw external IP."""
def setUp(self):
self.test_dir = tempfile.mkdtemp()
patcher = patch.object(WireGuardManager, '_syncconf', return_value=None)
patcher.start()
self.addCleanup(patcher.stop)
self.wg = WireGuardManager(self.test_dir, self.test_dir)
# Pin the configured port and external IP for deterministic endpoints.
self.wg._get_configured_port = MagicMock(return_value=51821)
self.wg.get_external_ip = MagicMock(return_value='198.51.100.7')
def tearDown(self):
shutil.rmtree(self.test_dir, ignore_errors=True)
def test_public_mode_uses_effective_domain_and_own_port(self):
cm = _FakeCM({'domain_mode': 'pic_ngo'}, 'alice.pic.ngo')
self.assertEqual(self.wg.get_advertised_endpoint(cm), 'alice.pic.ngo:51821')
def test_lan_mode_falls_back_to_external_ip(self):
cm = _FakeCM({'domain_mode': 'lan'}, 'cell')
self.assertEqual(self.wg.get_advertised_endpoint(cm), '198.51.100.7:51821')
def test_admin_override_wins(self):
cm = _FakeCM({'domain_mode': 'pic_ngo', 'wireguard_endpoint': 'vpn.example.com'}, 'alice.pic.ngo')
self.assertEqual(self.wg.get_advertised_endpoint(cm), 'vpn.example.com:51821')
def test_override_with_explicit_port_kept(self):
cm = _FakeCM({'domain_mode': 'lan', 'wireguard_endpoint': 'vpn.example.com:7777'}, 'cell')
self.assertEqual(self.wg.get_advertised_endpoint(cm), 'vpn.example.com:7777')
def test_none_when_no_domain_and_no_external_ip(self):
self.wg.get_external_ip = MagicMock(return_value=None)
cm = _FakeCM({'domain_mode': 'lan'}, 'cell')
self.assertIsNone(self.wg.get_advertised_endpoint(cm))
def test_public_mode_without_domain_falls_back_to_ip(self):
cm = _FakeCM({'domain_mode': 'cloudflare'}, '')
self.assertEqual(self.wg.get_advertised_endpoint(cm), '198.51.100.7:51821')
if __name__ == '__main__':
unittest.main()