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
+30 -18
View File
@@ -179,27 +179,39 @@ class NetworkManager(BaseServiceManager):
warnings.append(f'apply_ip_range failed: {e}')
return {'restarted': restarted, 'warnings': warnings}
def _build_dns_records(self, cell_name: str, ip_range: str) -> List[Dict]:
"""Build the standard set of DNS A records for the given subnet.
def _get_wg_server_ip(self) -> str:
"""Return the WireGuard server IP by reading wg0.conf. Falls back to 10.0.0.1."""
try:
import ipaddress
conf = os.path.join(self.config_dir, 'wireguard', 'wg_confs', 'wg0.conf')
with open(conf) as f:
for line in f:
stripped = line.strip()
if stripped.lower().startswith('address'):
addr = stripped.split('=', 1)[1].strip()
return str(ipaddress.ip_interface(addr).ip)
except Exception:
pass
return '10.0.0.1'
All user-facing names resolve to the Caddy reverse proxy (caddy IP) so
the Host header is passed through and Caddy routes based on it.
Exception: calendar/files/mail/webdav use dedicated virtual IPs so that
iptables per-service firewall rules can target them by destination IP.
api and webui also go through Caddy — they don't have their own VIPs and
their containers don't serve HTTP on port 80.
def _build_dns_records(self, cell_name: str, ip_range: str) -> List[Dict]:
"""Build the standard set of DNS A records.
All service names resolve to the WG server IP so they are reachable
from both local WG peers and cross-cell peers without Docker bridge
subnet conflicts. ensure_service_dnat() routes wg0:80 to Caddy, which
routes requests to the correct backend by Host header.
"""
import ip_utils
ips = ip_utils.get_service_ips(ip_range)
wg_ip = self._get_wg_server_ip()
return [
{'name': cell_name, 'type': 'A', 'value': ips['caddy']},
{'name': 'api', 'type': 'A', 'value': ips['caddy']},
{'name': 'webui', 'type': 'A', 'value': ips['caddy']},
{'name': 'calendar', 'type': 'A', 'value': ips['vip_calendar']},
{'name': 'files', 'type': 'A', 'value': ips['vip_files']},
{'name': 'mail', 'type': 'A', 'value': ips['vip_mail']},
{'name': 'webmail', 'type': 'A', 'value': ips['vip_mail']},
{'name': 'webdav', 'type': 'A', 'value': ips['vip_webdav']},
{'name': cell_name, 'type': 'A', 'value': wg_ip},
{'name': 'api', 'type': 'A', 'value': wg_ip},
{'name': 'webui', 'type': 'A', 'value': wg_ip},
{'name': 'calendar', 'type': 'A', 'value': wg_ip},
{'name': 'files', 'type': 'A', 'value': wg_ip},
{'name': 'mail', 'type': 'A', 'value': wg_ip},
{'name': 'webmail', 'type': 'A', 'value': wg_ip},
{'name': 'webdav', 'type': 'A', 'value': wg_ip},
]
def get_dns_records(self, zone: str = 'cell') -> List[Dict]: