feat: auto mutual WG pairing + subnet/domain conflict detection
**Auto mutual pairing** When Cell A imports Cell B's invite (POST /api/cells on A), A now immediately pushes its own invite to Cell B over the LAN (using the endpoint IP, before the WG tunnel exists) via the new endpoint: POST /api/cells/peer-sync/accept-invite Cell B auto-adds Cell A as a WireGuard peer and DNS forward, completing the bidirectional tunnel without any manual action on Cell B's UI. The endpoint is idempotent and unauthenticated (runs before WG tunnel). Previously, the pairing was one-sided: Cell A had Cell B as a WG peer but Cell B never had Cell A — the tunnel never established and all cross-cell operations silently failed. **Conflict detection (add_connection + accept-invite)** _check_invite_conflicts() now validates before connecting: - VPN subnet must not overlap own subnet or any already-connected cell's subnet - Domain must not match own domain or any already-connected cell's domain Returns clear error messages so the admin knows which cell to reconfigure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -183,6 +183,43 @@ def set_exit_offer(cell_name):
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/cells/peer-sync/accept-invite', methods=['POST'])
|
||||
def peer_sync_accept_invite():
|
||||
"""Machine-to-machine: a newly-connected cell pushes its own invite for mutual WG pairing.
|
||||
|
||||
Called by Cell A over the LAN (before the WG tunnel exists) immediately after Cell A
|
||||
imports Cell B's invite. Cell B uses this to add Cell A as a WireGuard peer and
|
||||
complete the bidirectional tunnel setup without manual admin action on Cell B.
|
||||
|
||||
No session auth — the request arrives before the WG tunnel is up. Basic sanity
|
||||
checks (valid invite format, no subnet/domain conflicts) are applied. The endpoint
|
||||
is idempotent: calling it again for an already-connected cell is a no-op.
|
||||
"""
|
||||
try:
|
||||
from app import cell_link_manager
|
||||
data = request.get_json(silent=True) or {}
|
||||
invite = data.get('invite')
|
||||
if not invite or not isinstance(invite, dict):
|
||||
return jsonify({'ok': False, 'error': 'invite object required'}), 400
|
||||
|
||||
for field in ('cell_name', 'public_key', 'vpn_subnet', 'dns_ip', 'domain'):
|
||||
if field not in invite:
|
||||
return jsonify({'ok': False, 'error': f'invite missing field: {field!r}'}), 400
|
||||
|
||||
if invite.get('version') not in (1, None):
|
||||
return jsonify({'ok': False, 'error': 'unsupported invite version'}), 400
|
||||
|
||||
link = cell_link_manager.accept_invite(invite)
|
||||
return jsonify({'ok': True, 'cell_name': link['cell_name']}), 201
|
||||
except ValueError as e:
|
||||
return jsonify({'ok': False, 'error': str(e)}), 400
|
||||
except RuntimeError as e:
|
||||
return jsonify({'ok': False, 'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
logger.error(f'accept-invite error: {e}')
|
||||
return jsonify({'ok': False, 'error': 'internal error'}), 500
|
||||
|
||||
|
||||
@bp.route('/api/cells/peer-sync/permissions', methods=['POST'])
|
||||
def peer_sync_permissions():
|
||||
"""Machine-to-machine endpoint: a connected cell pushes its mirrored permission state.
|
||||
|
||||
Reference in New Issue
Block a user