Fix Phase 1 permission sync: route push via cell-wireguard + DNAT receive

cell-api has no route to remote WG tunnel IPs — only cell-wireguard does.
Fix _push_permissions_to_remote() to use 'docker exec cell-wireguard curl'
so outbound sync HTTP traverses the WG tunnel from the right namespace.

On the receive side, add ensure_cell_api_dnat() which installs three
iptables rules inside cell-wireguard on startup:
  - PREROUTING DNAT: wg0:3000 → cell-api:3000 (Docker bridge IP)
  - POSTROUTING MASQUERADE: so cell-api's reply routes back via wg0
  - FORWARD ACCEPT: allow the wg0→eth0 forwarded traffic

Called from _apply_startup_enforcement() so rules survive container restarts.
Tests updated to mock subprocess.run instead of urllib.request.urlopen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 13:48:49 -04:00
parent a3d0cd5a48
commit 4ba79fd614
5 changed files with 149 additions and 28 deletions
+30 -17
View File
@@ -11,8 +11,7 @@ Each connection is stored in data/cell_links.json and manifests as:
import json
import logging
import os
import urllib.error
import urllib.request
import subprocess
from datetime import datetime
from typing import Any, Dict, List, Optional
@@ -108,6 +107,10 @@ class CellLinkManager:
The body inverts inbound/outbound: our inbound (what we share with them)
becomes their outbound (what they receive from us), and vice-versa.
Uses 'docker exec cell-wireguard curl' so the HTTP request originates
from inside cell-wireguard's network namespace, which has routes to
remote cell VPN subnets that cell-api (on the Docker bridge) lacks.
"""
url = link.get('remote_api_url')
if not url:
@@ -124,22 +127,32 @@ class CellLinkManager:
},
'sent_at': datetime.utcnow().isoformat() + 'Z',
}
payload = json.dumps(body).encode('utf-8')
req = urllib.request.Request(
url.rstrip('/') + '/api/cells/peer-sync/permissions',
data=payload,
method='POST',
headers={'Content-Type': 'application/json'},
)
payload = json.dumps(body)
endpoint = url.rstrip('/') + '/api/cells/peer-sync/permissions'
cmd = [
'docker', 'exec', 'cell-wireguard',
'curl', '-s', '-o', '/dev/null', '-w', '%{http_code}',
'-X', 'POST',
'-H', 'Content-Type: application/json',
'-d', payload,
'--max-time', str(_PUSH_TIMEOUT),
'--connect-timeout', '3',
endpoint,
]
try:
with urllib.request.urlopen(req, timeout=_PUSH_TIMEOUT) as resp:
if 200 <= resp.status < 300:
return {'ok': True, 'error': None}
return {'ok': False, 'error': f'HTTP {resp.status}'}
except urllib.error.HTTPError as e:
return {'ok': False, 'error': f'HTTP {e.code}'}
except urllib.error.URLError as e:
return {'ok': False, 'error': str(e.reason)[:200]}
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=_PUSH_TIMEOUT + 5
)
if result.returncode != 0:
err = (result.stderr or result.stdout or 'curl error').strip()[:200]
return {'ok': False, 'error': err}
status = result.stdout.strip()
if status.startswith('2'):
return {'ok': True, 'error': None}
return {'ok': False, 'error': f'HTTP {status}'}
except subprocess.TimeoutExpired:
return {'ok': False, 'error': 'timeout'}
except Exception as e:
return {'ok': False, 'error': str(e)[:200]}