fix: advertise WireGuard endpoint by domain, and reach linked cells over HTTPS
Unit Tests / test (push) Successful in 9m50s
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:
+48
-18
@@ -30,6 +30,17 @@ _BACKOFF_BASE_S = 60
|
|||||||
_BACKOFF_MAX_S = 3600
|
_BACKOFF_MAX_S = 3600
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_api_url(dns_ip: Optional[str]) -> Optional[str]:
|
||||||
|
"""Base URL for a linked cell's API, reached over the WG tunnel.
|
||||||
|
|
||||||
|
Cross-cell peer-sync goes to the remote's Caddy on 443 (the WireGuard server
|
||||||
|
DNATs VPN-IP:443 → Caddy → API). The API's own :3000 is bound to 127.0.0.1
|
||||||
|
and is NOT reachable from another cell, so we must target HTTPS/443, not
|
||||||
|
http://<ip>:3000.
|
||||||
|
"""
|
||||||
|
return f"https://{dns_ip}" if dns_ip else None
|
||||||
|
|
||||||
|
|
||||||
def _compute_next_retry(attempts: int) -> str:
|
def _compute_next_retry(attempts: int) -> str:
|
||||||
"""Return an ISO timestamp for the earliest next retry using capped exponential backoff."""
|
"""Return an ISO timestamp for the earliest next retry using capped exponential backoff."""
|
||||||
delay = min(_BACKOFF_BASE_S * (2 ** (attempts - 1)), _BACKOFF_MAX_S)
|
delay = min(_BACKOFF_BASE_S * (2 ** (attempts - 1)), _BACKOFF_MAX_S)
|
||||||
@@ -66,10 +77,12 @@ class CellLinkManager:
|
|||||||
changed = True
|
changed = True
|
||||||
# Phase 1 migration: permission-sync tracking fields
|
# Phase 1 migration: permission-sync tracking fields
|
||||||
if 'remote_api_url' not in link:
|
if 'remote_api_url' not in link:
|
||||||
link['remote_api_url'] = (
|
link['remote_api_url'] = _remote_api_url(link.get('dns_ip'))
|
||||||
f"http://{link['dns_ip']}:3000"
|
changed = True
|
||||||
if link.get('dns_ip') else None
|
# Migrate legacy http://<ip>:3000 URLs (unreachable across
|
||||||
)
|
# cells) to the HTTPS/Caddy form.
|
||||||
|
elif str(link.get('remote_api_url', '')).startswith('http://'):
|
||||||
|
link['remote_api_url'] = _remote_api_url(link.get('dns_ip'))
|
||||||
changed = True
|
changed = True
|
||||||
if 'last_push_status' not in link:
|
if 'last_push_status' not in link:
|
||||||
link['last_push_status'] = 'never'
|
link['last_push_status'] = 'never'
|
||||||
@@ -193,7 +206,10 @@ class CellLinkManager:
|
|||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
'docker', 'exec', 'cell-wireguard',
|
'docker', 'exec', 'cell-wireguard',
|
||||||
'curl', '-s', '-o', '/dev/null', '-w', '%{http_code}',
|
# -k: the request reaches Caddy by the remote's VPN IP over the
|
||||||
|
# encrypted WG tunnel, so the TLS cert (issued for the cell's domain)
|
||||||
|
# won't match the IP — the tunnel already authenticates the peer.
|
||||||
|
'curl', '-s', '-k', '-o', '/dev/null', '-w', '%{http_code}',
|
||||||
'-X', 'POST',
|
'-X', 'POST',
|
||||||
'-H', 'Content-Type: application/json',
|
'-H', 'Content-Type: application/json',
|
||||||
]
|
]
|
||||||
@@ -371,14 +387,24 @@ class CellLinkManager:
|
|||||||
# ── Public API ────────────────────────────────────────────────────────────
|
# ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def generate_invite(self, cell_name: str, domain: str) -> Dict[str, Any]:
|
def generate_invite(self, cell_name: str, domain: str) -> Dict[str, Any]:
|
||||||
"""Return an invite package describing this cell for another cell to import."""
|
"""Return an invite package describing this cell for another cell to import.
|
||||||
|
|
||||||
|
The endpoint advertises the cell's public domain (when in a DDNS/ACME
|
||||||
|
mode) plus this cell's own WireGuard port, rather than a raw external IP —
|
||||||
|
so the remote cell reaches us by name and a NAT/router can forward each
|
||||||
|
cell's distinct WG port to the right host.
|
||||||
|
"""
|
||||||
keys = self.wireguard_manager.get_keys()
|
keys = self.wireguard_manager.get_keys()
|
||||||
srv = self.wireguard_manager.get_server_config()
|
|
||||||
server_vpn_ip = self.wireguard_manager._get_configured_address().split('/')[0]
|
server_vpn_ip = self.wireguard_manager._get_configured_address().split('/')[0]
|
||||||
|
try:
|
||||||
|
from app import config_manager as _cm
|
||||||
|
except Exception:
|
||||||
|
_cm = None
|
||||||
|
endpoint = self.wireguard_manager.get_advertised_endpoint(_cm)
|
||||||
return {
|
return {
|
||||||
'cell_name': cell_name,
|
'cell_name': cell_name,
|
||||||
'public_key': keys['public_key'],
|
'public_key': keys['public_key'],
|
||||||
'endpoint': srv.get('endpoint'),
|
'endpoint': endpoint,
|
||||||
'vpn_subnet': self.wireguard_manager._get_configured_network(),
|
'vpn_subnet': self.wireguard_manager._get_configured_network(),
|
||||||
'dns_ip': server_vpn_ip,
|
'dns_ip': server_vpn_ip,
|
||||||
'domain': domain,
|
'domain': domain,
|
||||||
@@ -448,15 +474,16 @@ class CellLinkManager:
|
|||||||
def _push_invite_to_remote(self, link: Dict[str, Any]) -> Dict[str, Any]:
|
def _push_invite_to_remote(self, link: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Send OUR invite to the remote cell so it can complete mutual WG pairing.
|
"""Send OUR invite to the remote cell so it can complete mutual WG pairing.
|
||||||
|
|
||||||
Called immediately after adding the remote as our WG peer. Uses the
|
Called immediately after adding the remote as our WG peer, before the WG
|
||||||
remote's endpoint IP (LAN-reachable before the WG tunnel is up) rather
|
tunnel is up. Reaches the remote over the PUBLIC path at its advertised
|
||||||
than the WG-internal dns_ip. Non-fatal — one-sided pairing degrades
|
endpoint host (a domain in DDNS/ACME modes) on Caddy/443 — the API's :3000
|
||||||
gracefully; the admin can pair from the other side manually.
|
is 127.0.0.1-only and not reachable across cells. Non-fatal — one-sided
|
||||||
|
pairing degrades gracefully; the admin can pair from the other side.
|
||||||
"""
|
"""
|
||||||
endpoint = link.get('endpoint') or ''
|
endpoint = link.get('endpoint') or ''
|
||||||
if not endpoint:
|
if not endpoint:
|
||||||
return {'ok': False, 'error': 'no endpoint'}
|
return {'ok': False, 'error': 'no endpoint'}
|
||||||
# Parse LAN IP from endpoint (e.g. "192.168.31.52:51820" → "192.168.31.52")
|
# Host from endpoint (e.g. "alice.pic.ngo:51821" → "alice.pic.ngo").
|
||||||
try:
|
try:
|
||||||
host = endpoint.rsplit(':', 1)[0].strip('[]')
|
host = endpoint.rsplit(':', 1)[0].strip('[]')
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -471,11 +498,14 @@ class CellLinkManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {'ok': False, 'error': f'could not build own invite: {e}'}
|
return {'ok': False, 'error': f'could not build own invite: {e}'}
|
||||||
|
|
||||||
url = f'http://{host}:3000/api/cells/peer-sync/accept-invite'
|
url = f'https://{host}/api/cells/peer-sync/accept-invite'
|
||||||
payload = json.dumps({'invite': own_invite})
|
payload = json.dumps({'invite': own_invite})
|
||||||
cmd = [
|
cmd = [
|
||||||
'docker', 'exec', 'cell-wireguard',
|
'docker', 'exec', 'cell-wireguard',
|
||||||
'curl', '-s', '-o', '/dev/null', '-w', '%{http_code}',
|
# -k: endpoint may be a bare IP (LAN/fallback) whose cert won't match;
|
||||||
|
# accept-invite carries only public keys and the WG handshake is the
|
||||||
|
# real authentication.
|
||||||
|
'curl', '-s', '-k', '-o', '/dev/null', '-w', '%{http_code}',
|
||||||
'-X', 'POST',
|
'-X', 'POST',
|
||||||
'-H', 'Content-Type: application/json',
|
'-H', 'Content-Type: application/json',
|
||||||
'-d', payload,
|
'-d', payload,
|
||||||
@@ -537,7 +567,7 @@ class CellLinkManager:
|
|||||||
old_domain = existing.get('domain', '')
|
old_domain = existing.get('domain', '')
|
||||||
existing['dns_ip'] = invite['dns_ip']
|
existing['dns_ip'] = invite['dns_ip']
|
||||||
existing['vpn_subnet'] = invite['vpn_subnet']
|
existing['vpn_subnet'] = invite['vpn_subnet']
|
||||||
existing['remote_api_url'] = f"http://{invite['dns_ip']}:3000"
|
existing['remote_api_url'] = _remote_api_url(invite['dns_ip'])
|
||||||
if invite.get('endpoint'):
|
if invite.get('endpoint'):
|
||||||
existing['endpoint'] = invite['endpoint']
|
existing['endpoint'] = invite['endpoint']
|
||||||
if domain_changed:
|
if domain_changed:
|
||||||
@@ -599,7 +629,7 @@ class CellLinkManager:
|
|||||||
'domain': invite['domain'],
|
'domain': invite['domain'],
|
||||||
'connected_at': datetime.utcnow().isoformat(),
|
'connected_at': datetime.utcnow().isoformat(),
|
||||||
'permissions': _default_perms(),
|
'permissions': _default_perms(),
|
||||||
'remote_api_url': f"http://{invite['dns_ip']}:3000",
|
'remote_api_url': _remote_api_url(invite['dns_ip']),
|
||||||
'last_push_status': 'never',
|
'last_push_status': 'never',
|
||||||
'last_push_at': None,
|
'last_push_at': None,
|
||||||
'last_push_error': None,
|
'last_push_error': None,
|
||||||
@@ -659,7 +689,7 @@ class CellLinkManager:
|
|||||||
'domain': invite['domain'],
|
'domain': invite['domain'],
|
||||||
'connected_at': datetime.utcnow().isoformat(),
|
'connected_at': datetime.utcnow().isoformat(),
|
||||||
'permissions': perms,
|
'permissions': perms,
|
||||||
'remote_api_url': f"http://{invite['dns_ip']}:3000",
|
'remote_api_url': _remote_api_url(invite['dns_ip']),
|
||||||
'last_push_status': 'never',
|
'last_push_status': 'never',
|
||||||
'last_push_at': None,
|
'last_push_at': None,
|
||||||
'last_push_error': None,
|
'last_push_error': None,
|
||||||
|
|||||||
@@ -8,15 +8,11 @@ bp = Blueprint('wireguard', __name__)
|
|||||||
def _effective_endpoint(wireguard_manager, config_manager) -> str:
|
def _effective_endpoint(wireguard_manager, config_manager) -> str:
|
||||||
"""Return the WireGuard endpoint to embed in peer configs.
|
"""Return the WireGuard endpoint to embed in peer configs.
|
||||||
|
|
||||||
Uses wireguard_endpoint from identity config when set (admin override),
|
Prefers the cell's public domain (DDNS/ACME modes) or an admin override over
|
||||||
falling back to get_external_ip() detection.
|
the raw external IP, so a peer config points at a name that resolves to the
|
||||||
|
cell rather than a bare IP. See WireGuardManager.get_advertised_endpoint.
|
||||||
"""
|
"""
|
||||||
srv = wireguard_manager.get_server_config()
|
return wireguard_manager.get_advertised_endpoint(config_manager) or '<SERVER_IP>'
|
||||||
override = (config_manager.get_identity().get('wireguard_endpoint') or '').strip()
|
|
||||||
if override:
|
|
||||||
port = srv.get('port', 51820)
|
|
||||||
return override if ':' in override else f'{override}:{port}'
|
|
||||||
return srv.get('endpoint') or '<SERVER_IP>'
|
|
||||||
|
|
||||||
@bp.route('/api/wireguard/keys', methods=['GET'])
|
@bp.route('/api/wireguard/keys', methods=['GET'])
|
||||||
def get_wireguard_keys():
|
def get_wireguard_keys():
|
||||||
|
|||||||
@@ -1054,6 +1054,38 @@ class WireGuardManager(BaseServiceManager):
|
|||||||
'vpn_network': self._get_configured_network(),
|
'vpn_network': self._get_configured_network(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Domain modes whose effective domain is a publicly-resolvable FQDN that the
|
||||||
|
# WireGuard endpoint should advertise instead of a raw IP. In these modes the
|
||||||
|
# domain resolves (via DDNS/ACME) to the cell's public IP, so peers and linked
|
||||||
|
# cells reach the cell by name — which survives IP changes and lets a NAT/router
|
||||||
|
# forward each cell's WG port to the right host.
|
||||||
|
PUBLIC_DOMAIN_MODES = ('pic_ngo', 'cloudflare', 'duckdns', 'http01')
|
||||||
|
|
||||||
|
def get_advertised_endpoint(self, config_manager=None) -> Optional[str]:
|
||||||
|
"""Return the WireGuard endpoint (host:port) to advertise to peers/cells.
|
||||||
|
|
||||||
|
Preference order:
|
||||||
|
1. an explicit admin override (`_identity.wireguard_endpoint`),
|
||||||
|
2. the cell's public domain in a DDNS/ACME mode (`<domain>:<port>`),
|
||||||
|
3. the detected external IP (`<ip>:<port>`) — LAN/fallback.
|
||||||
|
|
||||||
|
The port is always this cell's own configured WireGuard port, so a cell
|
||||||
|
on a non-default port advertises it correctly (the router forwards that
|
||||||
|
public port to this host).
|
||||||
|
"""
|
||||||
|
port = self._get_configured_port()
|
||||||
|
identity = config_manager.get_identity() if config_manager is not None else {}
|
||||||
|
override = (identity.get('wireguard_endpoint') or '').strip()
|
||||||
|
if override:
|
||||||
|
return override if ':' in override else f'{override}:{port}'
|
||||||
|
mode = identity.get('domain_mode', 'lan')
|
||||||
|
if mode in self.PUBLIC_DOMAIN_MODES and config_manager is not None:
|
||||||
|
host = (config_manager.get_effective_domain() or '').strip()
|
||||||
|
if host:
|
||||||
|
return f'{host}:{port}'
|
||||||
|
ext = self.get_external_ip()
|
||||||
|
return f'{ext}:{port}' if ext else None
|
||||||
|
|
||||||
def get_peer_status(self, public_key: str) -> Dict[str, Any]:
|
def get_peer_status(self, public_key: str) -> Dict[str, Any]:
|
||||||
"""Return live handshake + transfer stats for a peer from `wg show`."""
|
"""Return live handshake + transfer stats for a peer from `wg show`."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ def _make_wg_mock():
|
|||||||
}
|
}
|
||||||
wg._get_configured_network.return_value = '10.0.0.0/24'
|
wg._get_configured_network.return_value = '10.0.0.0/24'
|
||||||
wg._get_configured_address.return_value = '10.0.0.1/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.add_cell_peer.return_value = True
|
||||||
wg.remove_peer.return_value = True
|
wg.remove_peer.return_value = True
|
||||||
return wg
|
return wg
|
||||||
@@ -82,6 +83,13 @@ class TestCellLinkManagerInvite(unittest.TestCase):
|
|||||||
self.assertEqual(invite['cell_name'], 'myhome')
|
self.assertEqual(invite['cell_name'], 'myhome')
|
||||||
self.assertEqual(invite['domain'], 'myhome.local')
|
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):
|
class TestCellLinkManagerConnections(unittest.TestCase):
|
||||||
|
|
||||||
@@ -182,7 +190,7 @@ class TestCellLinkManagerConnections(unittest.TestCase):
|
|||||||
result = self.mgr.accept_invite(updated_invite)
|
result = self.mgr.accept_invite(updated_invite)
|
||||||
|
|
||||||
self.assertEqual(result['dns_ip'], '10.1.0.2')
|
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.remove_cell_dns_forward.assert_called()
|
||||||
self.nm.add_cell_dns_forward.assert_called_with(
|
self.nm.add_cell_dns_forward.assert_called_with(
|
||||||
domain='office.cell', dns_ip='10.1.0.2')
|
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)
|
result = self.mgr._push_invite_to_remote(link)
|
||||||
self.assertFalse(result['ok'])
|
self.assertFalse(result['ok'])
|
||||||
|
|
||||||
def test_push_invite_sends_to_correct_lan_host(self):
|
def test_push_invite_sends_to_endpoint_host_over_https(self):
|
||||||
"""The curl URL must use the LAN IP from the endpoint, not the WG dns_ip."""
|
"""The curl targets the endpoint host on Caddy/HTTPS (443), not the WG
|
||||||
link = self._make_link(endpoint='192.168.31.52:51820')
|
dns_ip and not the internal :3000 API port."""
|
||||||
|
link = self._make_link(endpoint='alice.pic.ngo:51821')
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
def fake_run(cmd, **kw):
|
def fake_run(cmd, **kw):
|
||||||
@@ -493,10 +502,11 @@ class TestPushInviteToRemote(unittest.TestCase):
|
|||||||
self.mgr._push_invite_to_remote(link)
|
self.mgr._push_invite_to_remote(link)
|
||||||
|
|
||||||
url_in_cmd = captured['cmd'][-1]
|
url_in_cmd = captured['cmd'][-1]
|
||||||
self.assertIn('192.168.31.52', url_in_cmd)
|
self.assertEqual(url_in_cmd,
|
||||||
self.assertIn('accept-invite', url_in_cmd)
|
'https://alice.pic.ngo/api/cells/peer-sync/accept-invite')
|
||||||
# Must NOT use the WG dns_ip (10.1.0.1)
|
self.assertNotIn(':3000', url_in_cmd)
|
||||||
self.assertNotIn('10.1.0.1', 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'):
|
with patch('firewall_manager.apply_cell_rules'):
|
||||||
result = self.mgr.accept_invite(updated)
|
result = self.mgr.accept_invite(updated)
|
||||||
self.assertEqual(result['dns_ip'], '10.1.0.5')
|
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.remove_cell_dns_forward.assert_called()
|
||||||
self.nm.add_cell_dns_forward.assert_called_with(
|
self.nm.add_cell_dns_forward.assert_called_with(
|
||||||
domain='office.cell', dns_ip='10.1.0.5')
|
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):
|
def test_add_connection_sets_remote_api_url_from_dns_ip(self):
|
||||||
link = self._add_office()
|
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):
|
def test_add_connection_triggers_push(self):
|
||||||
push_mock = MagicMock(return_value={'ok': True, 'error': None})
|
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_status', link)
|
||||||
self.assertIn('last_push_at', link)
|
self.assertIn('last_push_at', link)
|
||||||
self.assertIn('last_remote_update_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.assertTrue(link['pending_push']) # pre-existing → marked pending
|
||||||
self.assertEqual(link['last_push_status'], 'never')
|
self.assertEqual(link['last_push_status'], 'never')
|
||||||
|
|
||||||
@@ -1330,6 +1341,24 @@ class TestPermissionSync(unittest.TestCase):
|
|||||||
raw = json.load(f)
|
raw = json.load(f)
|
||||||
self.assertIn('pending_push', raw[0])
|
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):
|
class TestExitOffer(unittest.TestCase):
|
||||||
"""Tests for Phase 2: exit-offer signaling."""
|
"""Tests for Phase 2: exit-offer signaling."""
|
||||||
|
|||||||
@@ -90,11 +90,13 @@ class TestWireGuardEndpoints(unittest.TestCase):
|
|||||||
'endpoint': '1.2.3.4:51820',
|
'endpoint': '1.2.3.4:51820',
|
||||||
'port': 51820,
|
'port': 51820,
|
||||||
}
|
}
|
||||||
|
mock_wg.get_advertised_endpoint.return_value = '1.2.3.4:51820'
|
||||||
r = self.client.get('/api/wireguard/server-config')
|
r = self.client.get('/api/wireguard/server-config')
|
||||||
self.assertEqual(r.status_code, 200)
|
self.assertEqual(r.status_code, 200)
|
||||||
data = json.loads(r.data)
|
data = json.loads(r.data)
|
||||||
self.assertIn('public_key', data)
|
self.assertIn('public_key', data)
|
||||||
self.assertIn('endpoint', data)
|
self.assertIn('endpoint', data)
|
||||||
|
self.assertEqual(data.get('effective_endpoint'), '1.2.3.4:51820')
|
||||||
|
|
||||||
@patch('app.wireguard_manager')
|
@patch('app.wireguard_manager')
|
||||||
def test_server_config_returns_500_on_exception(self, mock_wg):
|
def test_server_config_returns_500_on_exception(self, mock_wg):
|
||||||
|
|||||||
@@ -885,5 +885,60 @@ class TestCellRoutes(unittest.TestCase):
|
|||||||
mock_route.assert_called_once_with('10.1.0.0/24')
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
Reference in New Issue
Block a user