feat(cells): Phase 2 — exit-offer signaling between connected cells
Adds the ability for a cell to signal to a peer that it's willing to route internet traffic on their behalf. This is the signaling layer for Phase 3 (per-peer routing via exit cell). Changes: - cell_links.json: exit_offered (bool) + remote_exit_offered (bool) fields with lazy migration (default false for existing records) - _push_permissions_to_remote: includes exit_offered in the push body - apply_remote_permissions: accepts exit_offered kwarg; stores it as remote_exit_offered on the matching cell link - peer-sync receiver: passes exit_offered from body to apply_remote_permissions - CellLinkManager.set_exit_offered(cell_name, offered): persists + triggers push so the remote learns of our offer immediately - PUT /api/cells/<name>/exit-offer: REST endpoint to toggle the flag - 12 new tests covering all new paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -77,6 +77,13 @@ class CellLinkManager:
|
||||
if 'last_remote_update_at' not in link:
|
||||
link['last_remote_update_at'] = None
|
||||
changed = True
|
||||
# Phase 2 migration: exit-offer signaling fields
|
||||
if 'exit_offered' not in link:
|
||||
link['exit_offered'] = False
|
||||
changed = True
|
||||
if 'remote_exit_offered' not in link:
|
||||
link['remote_exit_offered'] = False
|
||||
changed = True
|
||||
if changed:
|
||||
self._save(links)
|
||||
return links
|
||||
@@ -146,6 +153,7 @@ class CellLinkManager:
|
||||
'outbound': dict(perms.get('inbound', {})),
|
||||
'inbound': dict(perms.get('outbound', {})),
|
||||
},
|
||||
'exit_offered': bool(link.get('exit_offered', False)),
|
||||
'sent_at': datetime.utcnow().isoformat() + 'Z',
|
||||
}
|
||||
payload = json.dumps(body)
|
||||
@@ -230,7 +238,8 @@ class CellLinkManager:
|
||||
logger.warning(f"Permission push to '{cell_name}' skipped (non-fatal): {e}")
|
||||
|
||||
def apply_remote_permissions(self, from_public_key: str,
|
||||
permissions: Dict[str, Any]) -> Dict[str, Any]:
|
||||
permissions: Dict[str, Any],
|
||||
exit_offered: bool = False) -> Dict[str, Any]:
|
||||
"""Store permissions pushed by a remote cell (identified by WG public key).
|
||||
|
||||
Validates service names, persists, and re-applies local iptables rules.
|
||||
@@ -247,6 +256,7 @@ class CellLinkManager:
|
||||
clean_outbound = {s: bool(out_raw.get(s, False)) for s in VALID_SERVICES}
|
||||
|
||||
link['permissions'] = {'inbound': clean_inbound, 'outbound': clean_outbound}
|
||||
link['remote_exit_offered'] = bool(exit_offered)
|
||||
link['last_remote_update_at'] = datetime.utcnow().isoformat()
|
||||
self._save(links)
|
||||
|
||||
@@ -445,6 +455,21 @@ class CellLinkManager:
|
||||
raise ValueError(f"Cell '{cell_name}' not found")
|
||||
return link.get('permissions', _default_perms())
|
||||
|
||||
def set_exit_offered(self, cell_name: str, offered: bool) -> Dict[str, Any]:
|
||||
"""Toggle whether THIS cell offers to route internet traffic for cell_name.
|
||||
|
||||
The new value is persisted locally then pushed to the remote cell so it
|
||||
knows our offer changed. Returns the updated link record.
|
||||
"""
|
||||
links = self._load()
|
||||
link = next((l for l in links if l['cell_name'] == cell_name), None)
|
||||
if not link:
|
||||
raise ValueError(f"Cell '{cell_name}' not found")
|
||||
link['exit_offered'] = bool(offered)
|
||||
self._save(links)
|
||||
self._try_push(cell_name, link)
|
||||
return link
|
||||
|
||||
def get_connection_status(self, cell_name: str) -> Dict[str, Any]:
|
||||
"""Return link record enriched with live WireGuard handshake status."""
|
||||
links = self._load()
|
||||
|
||||
+23
-1
@@ -162,6 +162,27 @@ def update_cell_permissions(cell_name):
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/cells/<cell_name>/exit-offer', methods=['PUT'])
|
||||
def set_exit_offer(cell_name):
|
||||
"""Toggle whether this cell offers to route internet for a connected cell.
|
||||
|
||||
Body: {"exit_offered": true|false}
|
||||
The new value is persisted and pushed to the remote cell.
|
||||
"""
|
||||
try:
|
||||
from app import cell_link_manager
|
||||
data = request.get_json(silent=True) or {}
|
||||
if 'exit_offered' not in data:
|
||||
return jsonify({'error': 'exit_offered field required'}), 400
|
||||
link = cell_link_manager.set_exit_offered(cell_name, bool(data['exit_offered']))
|
||||
return jsonify({'message': f"Exit offer for '{cell_name}' updated", 'link': link})
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 404
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting exit offer: {e}")
|
||||
return jsonify({'error': str(e)}), 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.
|
||||
@@ -197,8 +218,9 @@ def peer_sync_permissions():
|
||||
if svc not in VALID_SERVICES:
|
||||
return jsonify({'ok': False, 'error': f'unknown service: {svc!r}'}), 400
|
||||
|
||||
exit_offered = bool(data.get('exit_offered', False))
|
||||
from app import cell_link_manager
|
||||
cell_link_manager.apply_remote_permissions(sender_pubkey, perms)
|
||||
cell_link_manager.apply_remote_permissions(sender_pubkey, perms, exit_offered=exit_offered)
|
||||
return jsonify({'ok': True, 'applied_at': datetime.utcnow().isoformat()})
|
||||
except ValueError as e:
|
||||
return jsonify({'ok': False, 'error': str(e)}), 404
|
||||
|
||||
Reference in New Issue
Block a user