feat(cells): fix PIC-to-PIC connection + add service-sharing permissions
Phase 1 — connection fixes:
- routing_manager.stop(): remove iptables -F / -t nat -F nuclear flush that
would wipe WireGuard MASQUERADE and all peer rules on any UI stop action
- wireguard_manager.add_cell_peer(): reject vpn_subnet that overlaps the local
WG network (routing blackhole — was the root cause of no handshake)
- wireguard_manager._syncconf(): pass Endpoint to 'wg set' so cell peers with
static endpoints are synced to the kernel (not just AllowedIPs)
Phase 2 — service-sharing permissions backend:
- firewall_manager: add _cell_tag(), clear_cell_rules(), apply_cell_rules(),
apply_all_cell_rules() — iptables FORWARD rules for cell-to-cell traffic
using 'pic-cell-<name>' comment tags, distinct from 'pic-peer-*'
- app.py startup enforcement: call apply_all_cell_rules(cell_links) so rules
survive API restarts
- cell_link_manager: permissions schema {inbound, outbound} per service;
lazy migration for existing entries; update_permissions(), get_permissions();
apply_cell_rules wired into add_connection/remove_connection
- routes/cells.py: GET /api/cells/services, GET+PUT /api/cells/<n>/permissions;
RuntimeError now returns 400 (not 500) from add_connection
Removed broken 'test' cell (subnet 10.0.0.0/24 collided with local WG network).
Second PIC must use a distinct subnet (e.g. 10.0.1.0/24) before reconnecting.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -221,6 +221,83 @@ def apply_all_peer_rules(peers: List[Dict[str, Any]]) -> None:
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cell-to-cell firewall rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cell_tag(cell_name: str) -> str:
|
||||
"""iptables comment tag for cell rules — distinct prefix from pic-peer-* to prevent collision."""
|
||||
safe = re.sub(r'[^a-z0-9]', '-', cell_name.lower())
|
||||
return f'pic-cell-{safe}'
|
||||
|
||||
|
||||
def clear_cell_rules(cell_name: str) -> None:
|
||||
"""Remove all FORWARD rules tagged for this cell (atomic save/restore)."""
|
||||
tag = _cell_tag(cell_name)
|
||||
comment_re = re.compile(rf'--comment\s+["\']?{re.escape(tag)}["\']?(\s|$)')
|
||||
try:
|
||||
save = _wg_exec(['iptables-save'])
|
||||
if save.returncode != 0:
|
||||
return
|
||||
lines = save.stdout.splitlines()
|
||||
filtered = [l for l in lines if not comment_re.search(l)]
|
||||
if len(filtered) == len(lines):
|
||||
return
|
||||
restore_input = '\n'.join(filtered) + '\n'
|
||||
restore = subprocess.run(
|
||||
['docker', 'exec', '-i', WIREGUARD_CONTAINER, 'iptables-restore'],
|
||||
input=restore_input, capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if restore.returncode != 0:
|
||||
logger.warning(f"clear_cell_rules iptables-restore failed: {restore.stderr.strip()}")
|
||||
except Exception as e:
|
||||
logger.error(f"clear_cell_rules({cell_name}): {e}")
|
||||
|
||||
|
||||
def apply_cell_rules(cell_name: str, vpn_subnet: str, inbound_services: List[str]) -> bool:
|
||||
"""Apply FORWARD rules for a cell-to-cell peer.
|
||||
|
||||
Traffic from vpn_subnet is allowed only to service VIPs listed in
|
||||
inbound_services; all other cell traffic is DROPped. Cells get no
|
||||
internet or peer access — only explicit service VIPs.
|
||||
|
||||
Rule insertion order (last inserted → top of chain):
|
||||
1. Catch-all DROP for the subnet (inserted first → bottom)
|
||||
2. Per-service ACCEPT/DROP (inserted in reversed() order → top)
|
||||
"""
|
||||
try:
|
||||
tag = _cell_tag(cell_name)
|
||||
clear_cell_rules(cell_name)
|
||||
|
||||
# Catch-all DROP — inserted first so it ends up at the bottom
|
||||
_iptables(['-I', 'FORWARD', '-s', vpn_subnet,
|
||||
'-m', 'comment', '--comment', tag, '-j', 'DROP'])
|
||||
|
||||
# Per-service rules — inserted in reverse dict order, highest-priority last
|
||||
for service, svc_ip in reversed(list(SERVICE_IPS.items())):
|
||||
target = 'ACCEPT' if service in inbound_services else 'DROP'
|
||||
_iptables(['-I', 'FORWARD', '-s', vpn_subnet, '-d', svc_ip,
|
||||
'-m', 'comment', '--comment', tag, '-j', target])
|
||||
|
||||
logger.info(f"Applied cell rules for {cell_name} ({vpn_subnet}): inbound={inbound_services}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"apply_cell_rules({cell_name}): {e}")
|
||||
return False
|
||||
|
||||
|
||||
def apply_all_cell_rules(cell_links: List[Dict[str, Any]]) -> None:
|
||||
"""Re-apply firewall rules for all cell connections (called on startup)."""
|
||||
for link in cell_links:
|
||||
name = link.get('cell_name')
|
||||
subnet = link.get('vpn_subnet')
|
||||
if not name or not subnet:
|
||||
continue
|
||||
perms = link.get('permissions', {})
|
||||
inbound = [s for s, v in perms.get('inbound', {}).items() if v]
|
||||
apply_cell_rules(name, subnet, inbound)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DNS ACL (CoreDNS Corefile generation)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user