Add domain conflict validation when changing domain or accepting heal invite

Two gaps allowed a cell to take a domain already in use by a connected cell:

1. PUT /api/config domain change: added check against cell_link_manager's
   connected cells list before saving — returns 409 if the new domain
   collides with any connected cell's domain.

2. accept_invite healing path: a remote cell changing its domain via a
   re-invite was not validated against other connected cells' domains.
   Now calls _check_invite_conflicts(invite, exclude_cell=name) before
   applying any change.

Also: the healing path now detects domain changes (alongside dns_ip/
vpn_subnet/endpoint), updates the stored domain, and refreshes the DNS
forward rule when the domain changes.

Tests: 3 new domain-conflict tests in test_config_validation.py;
3 new accept_invite healing tests in test_cell_link_manager.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 09:46:58 -04:00
parent ac0c16c97b
commit c658d2b16c
4 changed files with 114 additions and 7 deletions
+18 -7
View File
@@ -511,26 +511,37 @@ class CellLinkManager:
links = self._load()
name = invite['cell_name']
# Already connected — check whether the remote's endpoint or subnet changed
# (e.g. the remote cell changed its WireGuard address) and heal if so.
# Already connected — check whether the remote's endpoint, subnet, or domain changed
# (e.g. the remote cell changed its WireGuard address or domain) and heal if so.
existing = next((l for l in links if l['cell_name'] == name), None)
if existing:
dns_changed = existing.get('dns_ip') != invite['dns_ip']
subnet_changed = existing.get('vpn_subnet') != invite['vpn_subnet']
endpoint_changed = (invite.get('endpoint') and
invite['endpoint'] != existing.get('endpoint'))
if dns_changed or subnet_changed or endpoint_changed:
domain_changed = (invite.get('domain') and
invite['domain'] != existing.get('domain'))
if dns_changed or subnet_changed or endpoint_changed or domain_changed:
# Before healing, verify the updated invite doesn't conflict with
# other connected cells (exclude this cell by name so it's not
# self-blocking when only endpoint/dns_ip changed).
self._check_invite_conflicts(invite, exclude_cell=name)
logger.info(
f"accept_invite: updating existing cell '{name}' "
f"(dns_ip: {existing.get('dns_ip')}{invite['dns_ip']}, "
f"vpn_subnet: {existing.get('vpn_subnet')}{invite['vpn_subnet']})"
f"vpn_subnet: {existing.get('vpn_subnet')}{invite['vpn_subnet']}, "
f"domain: {existing.get('domain')}{invite.get('domain')})"
)
old_subnet = existing.get('vpn_subnet', '')
old_domain = existing.get('domain', '')
existing['dns_ip'] = invite['dns_ip']
existing['vpn_subnet'] = invite['vpn_subnet']
existing['remote_api_url'] = f"http://{invite['dns_ip']}:3000"
if invite.get('endpoint'):
existing['endpoint'] = invite['endpoint']
if domain_changed:
existing['domain'] = invite['domain']
self._save(links)
# Update WG peer AllowedIPs to the new subnet
@@ -538,10 +549,10 @@ class CellLinkManager:
self.wireguard_manager.update_peer_ip(
existing['public_key'], invite['vpn_subnet'])
# Update DNS forward rule (remove old, add new)
if dns_changed:
# Update DNS forward rule — triggers on dns_ip OR domain change
if dns_changed or domain_changed:
try:
self.network_manager.remove_cell_dns_forward(existing['domain'])
self.network_manager.remove_cell_dns_forward(old_domain)
except Exception:
pass
self.network_manager.add_cell_dns_forward(
+7
View File
@@ -158,6 +158,13 @@ def update_config():
return jsonify({'error': 'domain must be 255 characters or fewer'}), 400
if not _DOMAIN_RE.match(v):
return jsonify({'error': 'Invalid domain: use only letters, digits, hyphens, dots'}), 400
from app import cell_link_manager as _clm
for _link in _clm.list_connections():
if _link.get('domain') == v:
return jsonify({'error': (
f"Domain {v!r} is already used by connected cell "
f"'{_link['cell_name']}' — each cell must use a unique domain"
)}), 409
if 'ip_range' in identity_updates:
_rfc1918 = [