feat: restore WireGuard peers after bootstrap and add VPN routing tests

apply_config() now calls _load_registered_peers() when wg0.conf is empty
so all active peers from peers.json are written back into the config file
after a bootstrap — preventing clients from losing tunnel access after
an API restart that regenerated wg0.conf from scratch.

Adds test_wireguard_vpn_routing.py (36 tests) covering:
- generate_config() PostUp/PostDown rules enabling internet forwarding
  (MASQUERADE + FORWARD ACCEPT required for internet-through-VPN)
- get_peer_config() DNS field pointing to cell-dns for domain resolution
- apply_config() bootstrap peer restoration from peers.json
- _load_registered_peers() filtering (inactive, missing fields, malformed)
- add_peer() /32 AllowedIPs enforcement to prevent route leaks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 15:33:57 -04:00
parent 78706d685f
commit 9418c3da5b
2 changed files with 469 additions and 0 deletions
+26
View File
@@ -206,6 +206,23 @@ class WireGuardManager(BaseServiceManager):
"""Return split-tunnel AllowedIPs: VPN subnet + Docker bridge."""
return f'{self._get_configured_network()}, 172.20.0.0/16'
def _load_registered_peers(self) -> list:
"""Read active peers from peers.json for wg0.conf reconstruction after bootstrap."""
import json as _json
peers_file = os.path.join(self.data_dir, 'peers.json')
try:
with open(peers_file) as f:
peers = _json.load(f)
return [
p for p in peers
if isinstance(p, dict)
and p.get('active', True)
and p.get('public_key')
and p.get('ip')
]
except Exception:
return []
def apply_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
"""Update wg0.conf interface fields and restart cell-wireguard."""
restarted = []
@@ -221,6 +238,15 @@ class WireGuardManager(BaseServiceManager):
# Bootstrap from generate_config() if file is empty or has no [Interface]
if not raw.strip() or '[Interface]' not in raw:
raw = self.generate_config()
# Restore all registered peers so clients can reconnect immediately
for peer in self._load_registered_peers():
raw += (
f'\n[Peer]\n'
f'# {peer.get("peer", "unknown")}\n'
f'PublicKey = {peer["public_key"]}\n'
f'AllowedIPs = {peer["ip"]}/32\n'
f'PersistentKeepalive = 25\n'
)
with open(cf, 'w') as f:
f.write(raw)
warnings.append('wg0.conf was empty — regenerated from keys')