feat: fix cross-cell service access — DNS DNAT, service DNAT, Caddy routing

DNS A records now return the WireGuard server IP (10.0.0.1) instead of
Docker bridge VIPs so cross-cell peers resolve service names correctly
regardless of their bridge subnet. DNAT rules (wg0:53→cell-dns:53 and
wg0:80→cell-caddy:80) are applied at startup. Caddy routes by Host header,
eliminating the Docker bridge subnet conflict. Firewall cell rules allow
DNS and service (Caddy) traffic from linked cell subnets. Split-tunnel
AllowedIPs now dynamically includes connected-cell VPN subnets and drops
the 172.20.0.0/16 range. Peers with route_via set now receive full-tunnel
config (0.0.0.0/0) so all their traffic exits via the remote cell.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-02 03:12:09 -04:00
parent f2f15eb17e
commit 9a800e3b6b
11 changed files with 325 additions and 146 deletions
+27 -3
View File
@@ -203,8 +203,25 @@ class WireGuardManager(BaseServiceManager):
return SERVER_NETWORK
def get_split_tunnel_ips(self) -> str:
"""Return split-tunnel AllowedIPs: VPN subnet + Docker bridge."""
return f'{self._get_configured_network()}, 172.20.0.0/16'
"""Return split-tunnel AllowedIPs: local VPN subnet + all connected cell VPN subnets.
172.20.0.0/16 is intentionally excluded — all services are accessed via the
WG server IP (ensure_service_dnat routes wg0:80 to Caddy). Including the
Docker bridge subnet would cause routing conflicts when cells share the same range.
"""
local_net = self._get_configured_network()
cell_links_file = os.path.join(self.data_dir, 'api', 'cell_links.json')
cell_nets = []
try:
with open(cell_links_file) as f:
links = json.load(f)
for link in links:
subnet = link.get('vpn_subnet', '')
if subnet and subnet != local_net:
cell_nets.append(subnet)
except Exception:
pass
return ', '.join([local_net] + cell_nets)
def _load_registered_peers(self) -> list:
"""Read active peers from peers.json for wg0.conf reconstruction after bootstrap."""
@@ -733,7 +750,14 @@ class WireGuardManager(BaseServiceManager):
if allowed_ips is None:
allowed_ips = self.FULL_TUNNEL_IPS
server_keys = self.get_keys()
peer_dns = _resolve_peer_dns()
# Use WG server IP for DNS: ensure_dns_dnat() routes wg0:53 → cell-dns.
# This works for both split-tunnel (10.0.x.x in AllowedIPs) and cross-cell peers.
addr_str = self._get_configured_address()
try:
import ipaddress as _ipaddress
peer_dns = str(_ipaddress.ip_interface(addr_str).ip)
except Exception:
peer_dns = _resolve_peer_dns()
port = self._get_configured_port()
endpoint = server_endpoint if ':' in server_endpoint else f'{server_endpoint}:{port}'
addr = peer_ip if '/' in peer_ip else f'{peer_ip}/32'