diff --git a/api/app.py b/api/app.py index d5e1698..e0b45cc 100644 --- a/api/app.py +++ b/api/app.py @@ -305,6 +305,8 @@ def _apply_startup_enforcement(): firewall_manager.apply_all_peer_rules(peers) firewall_manager.apply_all_cell_rules(cell_links) firewall_manager.ensure_cell_api_dnat() + firewall_manager.ensure_dns_dnat() + firewall_manager.ensure_service_dnat() # Restore any cell link WireGuard peers that were lost from wg0.conf # (happens if the container was rebuilt, wg0.conf was reset, etc.) _restore_cell_wg_peers(cell_links) @@ -337,7 +339,8 @@ def _bootstrap_dns(): cell_name = identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell')) domain = identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell')) ip_range = identity.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')) - network_manager.bootstrap_dns_records(cell_name, domain, ip_range) + # Bootstrap on first start; then always regenerate to ensure A records use WG server IP. + network_manager.apply_ip_range(ip_range, cell_name, domain) except Exception as e: logger.warning(f"DNS bootstrap failed (non-fatal): {e}") diff --git a/api/firewall_manager.py b/api/firewall_manager.py index 94a7e4c..70f8f16 100644 --- a/api/firewall_manager.py +++ b/api/firewall_manager.py @@ -193,10 +193,14 @@ def apply_peer_rules(peer_ip: str, settings: Dict[str, Any]) -> bool: '-m', 'comment', '--comment', comment, '-j', target]) # --- Step 3 (inserted last → ends up at TOP of chain) --- - # Per-service rules — inserted in reverse dict order so first service ends up at top - for service, svc_ip in reversed(list(SERVICE_IPS.items())): - target = 'ACCEPT' if service in service_access else 'DROP' - _iptables(['-I', 'FORWARD', '-s', peer_ip, '-d', svc_ip, + # Service access via Caddy: DNS returns WG server IP for all services; + # ensure_service_dnat() routes wg0:80 to Caddy. One ACCEPT/DROP rule + # controls service access; CoreDNS ACL enforces per-name granularity. + caddy_ip = _get_caddy_container_ip() + if caddy_ip: + target = 'ACCEPT' if service_access else 'DROP' + _iptables(['-I', 'FORWARD', '-s', peer_ip, '-d', caddy_ip, + '-p', 'tcp', '--dport', '80', '-m', 'comment', '--comment', comment, '-j', target]) logger.info(f"Applied rules for {peer_ip}: internet={internet_access} " @@ -298,24 +302,50 @@ def _get_cell_api_ip() -> Optional[str]: return r.stdout.strip() +def _get_dns_container_ip() -> str: + """Return cell-dns container's Docker bridge IP. Falls back to 172.20.0.3.""" + try: + r = _run(['docker', 'inspect', '--format', + '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}', + 'cell-dns'], check=False) + return r.stdout.strip() or '172.20.0.3' + except Exception: + return '172.20.0.3' + + +def _get_caddy_container_ip() -> str: + """Return cell-caddy container's Docker bridge IP. Falls back to 172.20.0.2.""" + try: + r = _run(['docker', 'inspect', '--format', + '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}', + 'cell-caddy'], check=False) + return r.stdout.strip() or '172.20.0.2' + except Exception: + return '172.20.0.2' + + def apply_cell_rules(cell_name: str, vpn_subnet: str, inbound_services: List[str], exit_relay: bool = False) -> 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, plus the - cell-api port (3000) for permission-sync pushes arriving via DNAT. + internet or peer access — only explicit service access via Caddy on + port 80, plus the cell-api port (3000) for permission-sync pushes. + + DNS (port 53) is always allowed so cell peers can resolve service names. + Service names resolve to the WG server IP; ensure_service_dnat() routes + wg0:80 to Caddy, which routes by Host header. When exit_relay=True, the remote cell's peers can route internet - traffic through this cell (Phase 3). A broad ACCEPT for traffic - going out eth0 is added below per-service rules but above catch-all. + traffic through this cell (Phase 3). Rule insertion order (first inserted = bottom, last inserted = top): 1. Catch-all DROP for the subnet (inserted first → bottom) 2. Exit relay ACCEPT (-o eth0) (if exit_relay, above catch-all) - 3. Per-service ACCEPT/DROP (inserted in reversed() order) - 4. API-sync ACCEPT (inserted last → top) + 3. Service ACCEPT to Caddy port 80 (if any inbound_services) + 4. DNS ACCEPT to cell-dns port 53 (UDP + TCP) + 5. API-sync ACCEPT (inserted last → top) """ try: tag = _cell_tag(cell_name) @@ -326,17 +356,27 @@ def apply_cell_rules(cell_name: str, vpn_subnet: str, inbound_services: List[str '-m', 'comment', '--comment', tag, '-j', 'DROP']) # Exit relay ACCEPT — allow internet-bound traffic from this cell's peers. - # Inserted ABOVE catch-all but BELOW per-service rules so service-level - # DROP rules still take effect for specific service VIPs. if exit_relay: _iptables(['-I', 'FORWARD', '-s', vpn_subnet, '-o', 'eth0', '-m', 'comment', '--comment', tag, '-j', 'ACCEPT']) - # 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]) + # Service access via Caddy — DNAT wg0:80 → Caddy; Host header routes to service. + # Only add ACCEPT if this cell has any inbound services granted. + if inbound_services: + caddy_ip = _get_caddy_container_ip() + if caddy_ip: + _iptables(['-I', 'FORWARD', '-s', vpn_subnet, '-d', caddy_ip, + '-p', 'tcp', '--dport', '80', + '-m', 'comment', '--comment', tag, '-j', 'ACCEPT']) + + # DNS ACCEPT — allow cross-cell peers to query CoreDNS via the WG server IP. + # ensure_dns_dnat() routes wg0:53 to cell-dns; FORWARD must allow it. + dns_ip = _get_dns_container_ip() + if dns_ip: + for proto in ('udp', 'tcp'): + _iptables(['-I', 'FORWARD', '-s', vpn_subnet, '-d', dns_ip, + '-p', proto, '--dport', '53', + '-m', 'comment', '--comment', tag, '-j', 'ACCEPT']) # API permission-sync ACCEPT — inserted LAST so it goes to position 1 (above # the catch-all DROP). Remote cells push permissions to our cell-api via the @@ -415,6 +455,68 @@ def ensure_cell_api_dnat() -> bool: return False +def ensure_dns_dnat() -> bool: + """DNAT wg0:53 (UDP+TCP) → cell-dns:53 so VPN peers use the WG server IP for DNS. + + Peers are configured with DNS = . Their DNS queries arrive on + wg0:53 and must be forwarded to cell-dns inside the Docker bridge. + """ + try: + dns_ip = _get_dns_container_ip() + if not dns_ip: + logger.warning('ensure_dns_dnat: cell-dns not found') + return False + for proto in ('udp', 'tcp'): + dnat_check = ['-t', 'nat', '-C', 'PREROUTING', '-i', 'wg0', '-p', proto, + '--dport', '53', '-j', 'DNAT', '--to-destination', f'{dns_ip}:53'] + dnat_add = ['-t', 'nat', '-A', 'PREROUTING', '-i', 'wg0', '-p', proto, + '--dport', '53', '-j', 'DNAT', '--to-destination', f'{dns_ip}:53'] + if _wg_exec(['iptables'] + dnat_check).returncode != 0: + _wg_exec(['iptables'] + dnat_add) + for proto in ('udp', 'tcp'): + fwd_check = ['-C', 'FORWARD', '-i', 'wg0', '-o', 'eth0', + '-p', proto, '--dport', '53', '-j', 'ACCEPT'] + fwd_add = ['-I', 'FORWARD', '-i', 'wg0', '-o', 'eth0', + '-p', proto, '--dport', '53', '-j', 'ACCEPT'] + if _wg_exec(['iptables'] + fwd_check).returncode != 0: + _wg_exec(['iptables'] + fwd_add) + logger.info(f'ensure_dns_dnat: wg0:53 → {dns_ip}:53') + return True + except Exception as e: + logger.error(f'ensure_dns_dnat: {e}') + return False + + +def ensure_service_dnat() -> bool: + """DNAT wg0:80 → cell-caddy:80 so VPN peers reach services via Host-header routing. + + All service DNS names resolve to the WG server IP. Traffic to wg0:80 is + forwarded to Caddy, which routes to the correct backend by Host header. + """ + try: + caddy_ip = _get_caddy_container_ip() + if not caddy_ip: + logger.warning('ensure_service_dnat: cell-caddy not found') + return False + dnat_check = ['-t', 'nat', '-C', 'PREROUTING', '-i', 'wg0', '-p', 'tcp', + '--dport', '80', '-j', 'DNAT', '--to-destination', f'{caddy_ip}:80'] + dnat_add = ['-t', 'nat', '-A', 'PREROUTING', '-i', 'wg0', '-p', 'tcp', + '--dport', '80', '-j', 'DNAT', '--to-destination', f'{caddy_ip}:80'] + if _wg_exec(['iptables'] + dnat_check).returncode != 0: + _wg_exec(['iptables'] + dnat_add) + fwd_check = ['-C', 'FORWARD', '-i', 'wg0', '-o', 'eth0', + '-p', 'tcp', '--dport', '80', '-j', 'ACCEPT'] + fwd_add = ['-I', 'FORWARD', '-i', 'wg0', '-o', 'eth0', + '-p', 'tcp', '--dport', '80', '-j', 'ACCEPT'] + if _wg_exec(['iptables'] + fwd_check).returncode != 0: + _wg_exec(['iptables'] + fwd_add) + logger.info(f'ensure_service_dnat: wg0:80 → {caddy_ip}:80') + return True + except Exception as e: + logger.error(f'ensure_service_dnat: {e}') + return False + + # --------------------------------------------------------------------------- # DNS ACL (CoreDNS Corefile generation) # --------------------------------------------------------------------------- diff --git a/api/network_manager.py b/api/network_manager.py index ba1d1bb..48b5106 100644 --- a/api/network_manager.py +++ b/api/network_manager.py @@ -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]: diff --git a/api/routes/peer_dashboard.py b/api/routes/peer_dashboard.py index 1a2e474..0ed8416 100644 --- a/api/routes/peer_dashboard.py +++ b/api/routes/peer_dashboard.py @@ -77,7 +77,9 @@ def peer_services(): if peer_private_key: try: internet_access = peer.get('internet_access', True) - allowed_ips = wireguard_manager.FULL_TUNNEL_IPS if internet_access else wireguard_manager.get_split_tunnel_ips() + route_via = peer.get('route_via') + use_full = internet_access or bool(route_via) + allowed_ips = wireguard_manager.FULL_TUNNEL_IPS if use_full else wireguard_manager.get_split_tunnel_ips() wg_config = wireguard_manager.get_peer_config( peer_name=peer_name, peer_ip=peer_ip, diff --git a/api/routes/wireguard.py b/api/routes/wireguard.py index 9f4639f..2346d92 100644 --- a/api/routes/wireguard.py +++ b/api/routes/wireguard.py @@ -176,7 +176,11 @@ def get_peer_config(): allowed_ips = data.get('allowed_ips') or None if not allowed_ips and registered: internet_access = registered.get('internet_access', True) - allowed_ips = wireguard_manager.FULL_TUNNEL_IPS if internet_access else wireguard_manager.get_split_tunnel_ips() + route_via = registered.get('route_via') + # Full tunnel when internet is allowed OR when route_via is set + # (route_via exits via a remote cell — all traffic must go through the tunnel) + use_full = internet_access or bool(route_via) + allowed_ips = wireguard_manager.FULL_TUNNEL_IPS if use_full else wireguard_manager.get_split_tunnel_ips() result = wireguard_manager.get_peer_config( peer_name=peer_name, diff --git a/api/wireguard_manager.py b/api/wireguard_manager.py index bb9449a..17ddcba 100644 --- a/api/wireguard_manager.py +++ b/api/wireguard_manager.py @@ -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' diff --git a/tests/test_firewall_manager.py b/tests/test_firewall_manager.py index bd45dc1..6730d04 100644 --- a/tests/test_firewall_manager.py +++ b/tests/test_firewall_manager.py @@ -205,6 +205,8 @@ class TestGenerateCorefileWithCellLinks(unittest.TestCase): class TestApplyPeerRules(unittest.TestCase): """Verify correct iptables calls for full-internet vs split-tunnel peers.""" + _FAKE_CADDY_IP = '172.20.0.2' + def _run_apply(self, peer_ip, settings): calls_made = [] @@ -215,7 +217,9 @@ class TestApplyPeerRules(unittest.TestCase): m.stdout = '' return m - with patch.object(firewall_manager, '_wg_exec', side_effect=fake_wg_exec): + with patch.object(firewall_manager, '_wg_exec', side_effect=fake_wg_exec), \ + patch.object(firewall_manager, '_get_caddy_container_ip', + return_value=self._FAKE_CADDY_IP): firewall_manager.apply_peer_rules(peer_ip, settings) return calls_made @@ -239,23 +243,23 @@ class TestApplyPeerRules(unittest.TestCase): self.assertIn('DROP', targets) self.assertIn('ACCEPT', targets) - def test_service_access_restriction_generates_drop(self): + def test_service_access_restriction_uses_caddy_rule(self): + """service_access controls access via a single Caddy ACCEPT/DROP rule, not per-VIP rules.""" calls = self._run_apply('10.0.0.4', {'internet_access': False, 'service_access': ['calendar'], 'peer_access': True}) iptables_calls = [c for c in calls if 'iptables' in c] - # files/mail/webdav should be DROPped, calendar ACCEPTed - targets_with_ips = [ - (c[c.index('-d') + 1], c[c.index('-j') + 1]) - for c in iptables_calls - if '-d' in c and '-j' in c - ] - svc_rules = {ip: t for ip, t in targets_with_ips - if ip in firewall_manager.SERVICE_IPS.values()} - calendar_ip = firewall_manager.SERVICE_IPS['calendar'] - files_ip = firewall_manager.SERVICE_IPS['files'] - self.assertEqual(svc_rules.get(calendar_ip), 'ACCEPT') - self.assertEqual(svc_rules.get(files_ip), 'DROP') + # Caddy rule should be ACCEPT (any non-empty service_access) + caddy_rules = [c for c in iptables_calls + if '-d' in c and self._FAKE_CADDY_IP in c + and '--dport' in c and '80' in c] + self.assertTrue(caddy_rules, "Expected a Caddy port-80 rule for service access") + target = caddy_rules[-1][caddy_rules[-1].index('-j') + 1] + self.assertEqual(target, 'ACCEPT', "Non-empty service_access should ACCEPT Caddy") + # No per-VIP rules — per-service control is at DNS ACL level + for svc_ip in firewall_manager.SERVICE_IPS.values(): + vip_rules = [c for c in iptables_calls if '-d' in c and svc_ip in c] + self.assertFalse(vip_rules, f"No per-VIP FORWARD rules expected for {svc_ip}") def test_all_rules_tagged_with_peer_comment(self): calls = self._run_apply('10.0.0.2', {'internet_access': True, @@ -380,18 +384,21 @@ class TestUpdateServiceIps(unittest.TestCase): self.assertEqual(set(firewall_manager.SERVICE_IPS.keys()), {'calendar', 'files', 'mail', 'webdav'}) - def test_apply_peer_rules_uses_updated_ips(self): + def test_apply_peer_rules_uses_caddy_not_vips(self): + """Service access uses Caddy IP for FORWARD rules, not SERVICE_IPS VIPs.""" firewall_manager.update_service_ips('10.0.0.0/24') called_with = [] + _CADDY_IP = '172.20.0.2' def fake_wg_exec(args): called_with.append(args) m = MagicMock() - m.returncode = 1 # simulate rule-doesn't-exist → _ensure_rule inserts + m.returncode = 1 return m with patch.object(firewall_manager, '_wg_exec', side_effect=fake_wg_exec), \ - patch.object(firewall_manager, 'clear_peer_rules'): + patch.object(firewall_manager, 'clear_peer_rules'), \ + patch.object(firewall_manager, '_get_caddy_container_ip', return_value=_CADDY_IP): firewall_manager.apply_peer_rules('10.0.0.5', { 'internet_access': True, 'service_access': ['calendar'], @@ -400,9 +407,10 @@ class TestUpdateServiceIps(unittest.TestCase): iptables_calls = [c for c in called_with if c and c[0] == 'iptables'] dest_ips = [c[c.index('-d') + 1] for c in iptables_calls if '-d' in c] - # calendar vIP should now be 10.0.0.21 - self.assertIn('10.0.0.21', dest_ips) - # old IP must not appear + # Caddy IP should appear for service access + self.assertIn(_CADDY_IP, dest_ips) + # VIPs (old or updated) must not appear — service access is via Caddy + self.assertNotIn('10.0.0.21', dest_ips) self.assertNotIn('172.20.0.21', dest_ips) @@ -416,9 +424,11 @@ class TestCellRules(unittest.TestCase): # ── helpers ─────────────────────────────────────────────────────────────── _FAKE_API_IP = '172.20.0.10' + _FAKE_CADDY_IP = '172.20.0.2' + _FAKE_DNS_IP = '172.20.0.3' def _capture_apply(self, cell_name, vpn_subnet, inbound_services): - """Run apply_cell_rules with _wg_exec and _get_cell_api_ip mocked.""" + """Run apply_cell_rules with _wg_exec and container IP helpers mocked.""" calls_made = [] def fake_wg_exec(args): @@ -429,7 +439,9 @@ class TestCellRules(unittest.TestCase): return m with patch.object(firewall_manager, '_wg_exec', side_effect=fake_wg_exec), \ - patch.object(firewall_manager, '_get_cell_api_ip', return_value=self._FAKE_API_IP): + patch.object(firewall_manager, '_get_cell_api_ip', return_value=self._FAKE_API_IP), \ + patch.object(firewall_manager, '_get_caddy_container_ip', return_value=self._FAKE_CADDY_IP), \ + patch.object(firewall_manager, '_get_dns_container_ip', return_value=self._FAKE_DNS_IP): firewall_manager.apply_cell_rules(cell_name, vpn_subnet, inbound_services) return [c for c in calls_made if 'iptables' in c] @@ -480,18 +492,18 @@ class TestCellRules(unittest.TestCase): self.assertTrue(subnet_drops, "Expected a catch-all DROP rule for the subnet") def test_apply_cell_rules_sends_accept_for_allowed_service(self): - """apply_cell_rules inserts ACCEPT for the calendar VIP when calendar is in inbound.""" + """apply_cell_rules inserts Caddy ACCEPT when inbound_services is non-empty.""" calls = self._capture_apply('office', '10.0.1.0/24', ['calendar']) - calendar_ip = firewall_manager.SERVICE_IPS['calendar'] - calendar_targets = self._targets_for_dest(calls, calendar_ip) - self.assertIn('ACCEPT', calendar_targets) + caddy_targets = self._targets_for_dest(calls, self._FAKE_CADDY_IP) + self.assertIn('ACCEPT', caddy_targets, + "Expected ACCEPT to Caddy when inbound_services is non-empty") - def test_apply_cell_rules_sends_drop_for_disallowed_service(self): - """apply_cell_rules inserts DROP for a service not in inbound_services.""" - calls = self._capture_apply('office', '10.0.1.0/24', ['calendar']) - files_ip = firewall_manager.SERVICE_IPS['files'] - files_targets = self._targets_for_dest(calls, files_ip) - self.assertIn('DROP', files_targets) + def test_apply_cell_rules_no_caddy_accept_when_no_inbound(self): + """apply_cell_rules does NOT insert Caddy ACCEPT when inbound_services is empty.""" + calls = self._capture_apply('office', '10.0.1.0/24', []) + caddy_targets = self._targets_for_dest(calls, self._FAKE_CADDY_IP) + self.assertNotIn('ACCEPT', caddy_targets, + "No Caddy ACCEPT expected when inbound_services is empty") def test_apply_cell_rules_accepts_api_sync_traffic(self): """apply_cell_rules inserts ACCEPT for cell-api:3000 so permission-sync pushes pass.""" @@ -517,7 +529,9 @@ class TestCellRules(unittest.TestCase): m = MagicMock(); m.returncode = 0; m.stdout = ''; return m with patch.object(firewall_manager, '_wg_exec', side_effect=fake_wg_exec), \ - patch.object(firewall_manager, '_get_cell_api_ip', return_value='172.20.0.10'): + patch.object(firewall_manager, '_get_cell_api_ip', return_value='172.20.0.10'), \ + patch.object(firewall_manager, '_get_caddy_container_ip', return_value='172.20.0.2'), \ + patch.object(firewall_manager, '_get_dns_container_ip', return_value='172.20.0.3'): firewall_manager.apply_cell_rules('office', '10.0.1.0/24', []) # The API-sync ACCEPT must be the LAST -I FORWARD insertion so it sits at position 1 @@ -527,26 +541,28 @@ class TestCellRules(unittest.TestCase): # ── apply_cell_rules — empty inbound (all-deny) ─────────────────────────── - def test_apply_cell_rules_empty_inbound_all_drop(self): - """With inbound_services=[], all per-service rules are DROP.""" + def test_apply_cell_rules_empty_inbound_no_service_accept(self): + """With inbound_services=[], no service ACCEPT is added; catch-all DROP blocks traffic.""" calls = self._capture_apply('office', '10.0.1.0/24', []) + # No ACCEPT to Caddy + caddy_targets = self._targets_for_dest(calls, self._FAKE_CADDY_IP) + self.assertNotIn('ACCEPT', caddy_targets, + "No Caddy ACCEPT expected with empty inbound_services") + # No per-VIP rules at all for service, svc_ip in firewall_manager.SERVICE_IPS.items(): svc_targets = self._targets_for_dest(calls, svc_ip) - self.assertTrue(svc_targets, - f"Expected at least one rule for {service} ({svc_ip})") - self.assertNotIn('ACCEPT', svc_targets, - f"{service} should be DROP when not in inbound_services") + self.assertFalse(svc_targets, + f"No per-VIP rules expected for {service} ({svc_ip})") # ── apply_cell_rules — all inbound (all-accept) ─────────────────────────── - def test_apply_cell_rules_all_inbound_all_accept(self): - """With all four services in inbound, all per-service rules are ACCEPT.""" + def test_apply_cell_rules_all_inbound_caddy_accept(self): + """With all four services in inbound, an ACCEPT rule is added for Caddy port 80.""" all_services = list(firewall_manager.SERVICE_IPS.keys()) calls = self._capture_apply('office', '10.0.1.0/24', all_services) - for service, svc_ip in firewall_manager.SERVICE_IPS.items(): - svc_targets = self._targets_for_dest(calls, svc_ip) - self.assertIn('ACCEPT', svc_targets, - f"{service} should be ACCEPT when in inbound_services") + caddy_targets = self._targets_for_dest(calls, self._FAKE_CADDY_IP) + self.assertIn('ACCEPT', caddy_targets, + "Expected ACCEPT to Caddy when all services are in inbound_services") # ── apply_cell_rules — all rules tagged ─────────────────────────────────── diff --git a/tests/test_network_manager.py b/tests/test_network_manager.py index 40216a7..1ee9d33 100644 --- a/tests/test_network_manager.py +++ b/tests/test_network_manager.py @@ -284,20 +284,24 @@ class TestBootstrapDnsRecords(unittest.TestCase): self.assertTrue(os.path.exists(zone_file)) @patch('subprocess.run') - def test_contains_default_caddy_ip(self, _mock): + def test_contains_wg_server_ip(self, _mock): + """Zone file records now use WG server IP (10.0.0.1) not Docker VIPs.""" self.nm.bootstrap_dns_records('mycell', 'cell') zone_file = os.path.join(self.nm.dns_zones_dir, 'cell.zone') content = open(zone_file).read() - self.assertIn('172.20.0.2', content) # caddy + self.assertIn('10.0.0.1', content) # WG server IP for all services + self.assertNotIn('172.20.0.2', content) # Caddy VIP no longer in zone + self.assertNotIn('172.20.0.21', content) # Service VIPs no longer in zone @patch('subprocess.run') - def test_custom_ip_range_used(self, _mock): + def test_custom_ip_range_does_not_affect_service_ips(self, _mock): + """ip_range is no longer used for service record IPs; WG server IP is used.""" self.nm.bootstrap_dns_records('mycell', 'cell', ip_range='10.5.0.0/24') zone_file = os.path.join(self.nm.dns_zones_dir, 'cell.zone') content = open(zone_file).read() - self.assertIn('10.5.0.2', content) # caddy - self.assertIn('10.5.0.21', content) # vip_calendar - self.assertNotIn('172.20', content) + self.assertIn('10.0.0.1', content) # WG server IP + self.assertNotIn('10.5.0.2', content) # old caddy pattern gone + self.assertNotIn('10.5.0.21', content) # old VIP pattern gone @patch('subprocess.run') def test_idempotent_skips_existing_zone(self, _mock): @@ -324,15 +328,15 @@ class TestApplyIpRange(unittest.TestCase): shutil.rmtree(self.test_dir) @patch('subprocess.run') - def test_zone_file_updated_with_new_ips(self, _mock): - # Bootstrap with default range, then change to 10.0.0.0/24 + def test_zone_file_updated_with_wg_server_ip(self, _mock): + """apply_ip_range regenerates zone with WG server IP for all service records.""" self.nm.bootstrap_dns_records('mycell', 'cell', '172.20.0.0/16') result = self.nm.apply_ip_range('10.0.0.0/24', 'mycell', 'cell') zone_file = os.path.join(self.nm.dns_zones_dir, 'cell.zone') content = open(zone_file).read() - self.assertIn('10.0.0.2', content) # caddy - self.assertIn('10.0.0.21', content) # vip_calendar - self.assertNotIn('172.20', content) + self.assertIn('10.0.0.1', content) # WG server IP for all services + self.assertNotIn('172.20.0.2', content) # old Caddy pattern gone + self.assertNotIn('172.20.0.21', content) # old VIP pattern gone @patch('subprocess.run') def test_returns_restarted_on_success(self, _mock): diff --git a/tests/test_peer_dashboard_services.py b/tests/test_peer_dashboard_services.py index fc83cd6..0e3a6eb 100644 --- a/tests/test_peer_dashboard_services.py +++ b/tests/test_peer_dashboard_services.py @@ -465,10 +465,15 @@ class TestPeerEndpointAccessControl: class TestDNSZoneRecords: """ Verify that network_manager._build_dns_records() generates the correct IPs. - api and webui must point to Caddy (not their container IPs) so Caddy can - reverse-proxy them — their containers don't listen on port 80. + + All service names now resolve to the WG server IP (10.0.0.1) rather than + Docker VIPs. ensure_service_dnat() routes wg0:80 → Caddy; Caddy routes to + the correct backend by Host header. This allows cross-cell peers to reach + services without Docker bridge subnet conflicts. """ + _WG_SERVER_IP = '10.0.0.1' + def setUp(self): pass @@ -477,59 +482,59 @@ class TestDNSZoneRecords: mgr = nm.NetworkManager.__new__(nm.NetworkManager) return mgr._build_dns_records(cell_name, ip_range) - def test_api_resolves_to_caddy_not_api_container(self): + def test_api_resolves_to_wg_server_ip(self): records = self._records() api_rec = next((r for r in records if r['name'] == 'api'), None) assert api_rec is not None, "No DNS record for 'api'" - assert api_rec['value'] == '172.20.0.2', ( - f"api.dev should resolve to Caddy (172.20.0.2), not the API container " - f"(172.20.0.10); got {api_rec['value']}" + assert api_rec['value'] == self._WG_SERVER_IP, ( + f"api.dev should resolve to WG server IP ({self._WG_SERVER_IP}); " + f"got {api_rec['value']}" ) - def test_webui_resolves_to_caddy_not_webui_container(self): + def test_webui_resolves_to_wg_server_ip(self): records = self._records() rec = next((r for r in records if r['name'] == 'webui'), None) assert rec is not None, "No DNS record for 'webui'" - assert rec['value'] == '172.20.0.2', ( - f"webui.dev should resolve to Caddy (172.20.0.2), not the WebUI container " - f"(172.20.0.11); got {rec['value']}" + assert rec['value'] == self._WG_SERVER_IP, ( + f"webui.dev should resolve to WG server IP ({self._WG_SERVER_IP}); " + f"got {rec['value']}" ) - def test_calendar_uses_vip(self): + def test_calendar_resolves_to_wg_server_ip(self): records = self._records() rec = next((r for r in records if r['name'] == 'calendar'), None) - assert rec and rec['value'] == '172.20.0.21', \ - f"calendar.dev VIP should be 172.20.0.21; got {rec}" + assert rec and rec['value'] == self._WG_SERVER_IP, \ + f"calendar.dev should resolve to WG server IP; got {rec}" - def test_files_uses_vip(self): + def test_files_resolves_to_wg_server_ip(self): records = self._records() rec = next((r for r in records if r['name'] == 'files'), None) - assert rec and rec['value'] == '172.20.0.22', \ - f"files.dev VIP should be 172.20.0.22; got {rec}" + assert rec and rec['value'] == self._WG_SERVER_IP, \ + f"files.dev should resolve to WG server IP; got {rec}" - def test_mail_uses_vip(self): + def test_mail_resolves_to_wg_server_ip(self): records = self._records() rec = next((r for r in records if r['name'] == 'mail'), None) - assert rec and rec['value'] == '172.20.0.23', \ - f"mail.dev VIP should be 172.20.0.23; got {rec}" + assert rec and rec['value'] == self._WG_SERVER_IP, \ + f"mail.dev should resolve to WG server IP; got {rec}" - def test_webmail_uses_mail_vip(self): + def test_webmail_resolves_to_wg_server_ip(self): records = self._records() rec = next((r for r in records if r['name'] == 'webmail'), None) - assert rec and rec['value'] == '172.20.0.23', \ - f"webmail.dev should share the mail VIP 172.20.0.23; got {rec}" + assert rec and rec['value'] == self._WG_SERVER_IP, \ + f"webmail.dev should resolve to WG server IP; got {rec}" - def test_webdav_uses_vip(self): + def test_webdav_resolves_to_wg_server_ip(self): records = self._records() rec = next((r for r in records if r['name'] == 'webdav'), None) - assert rec and rec['value'] == '172.20.0.24', \ - f"webdav.dev VIP should be 172.20.0.24; got {rec}" + assert rec and rec['value'] == self._WG_SERVER_IP, \ + f"webdav.dev should resolve to WG server IP; got {rec}" - def test_cell_name_resolves_to_caddy(self): + def test_cell_name_resolves_to_wg_server_ip(self): records = self._records(cell_name='mypic') rec = next((r for r in records if r['name'] == 'mypic'), None) - assert rec and rec['value'] == '172.20.0.2', \ - f"mypic.dev should resolve to Caddy (172.20.0.2); got {rec}" + assert rec and rec['value'] == self._WG_SERVER_IP, \ + f"mypic.dev should resolve to WG server IP; got {rec}" def test_all_records_are_type_a(self): records = self._records() @@ -540,21 +545,23 @@ class TestDNSZoneRecords: class TestDNSZoneRecordsWithPytest: """Same as above but using pytest-style (no setUp/tearDown).""" + _WG_SERVER_IP = '10.0.0.1' + @pytest.fixture def records(self): import network_manager as nm mgr = nm.NetworkManager.__new__(nm.NetworkManager) return mgr._build_dns_records('pic0', '172.20.0.0/16') - def test_api_resolves_to_caddy(self, records): + def test_api_resolves_to_wg_server_ip(self, records): rec = next((r for r in records if r['name'] == 'api'), None) - assert rec and rec['value'] == '172.20.0.2', \ - f"api.dev should point to Caddy (172.20.0.2); got {rec}" + assert rec and rec['value'] == self._WG_SERVER_IP, \ + f"api.dev should point to WG server IP ({self._WG_SERVER_IP}); got {rec}" - def test_webui_resolves_to_caddy(self, records): + def test_webui_resolves_to_wg_server_ip(self, records): rec = next((r for r in records if r['name'] == 'webui'), None) - assert rec and rec['value'] == '172.20.0.2', \ - f"webui.dev should point to Caddy (172.20.0.2); got {rec}" + assert rec and rec['value'] == self._WG_SERVER_IP, \ + f"webui.dev should point to WG server IP ({self._WG_SERVER_IP}); got {rec}" # ─────────────────── Caddyfile generation ───────────────────────────────────── diff --git a/tests/test_wireguard_manager.py b/tests/test_wireguard_manager.py index a7e87d5..9b82721 100644 --- a/tests/test_wireguard_manager.py +++ b/tests/test_wireguard_manager.py @@ -244,7 +244,7 @@ class TestWireGuardManager(unittest.TestCase): self.assertIn('[Peer]', config) self.assertIn('PrivateKey', config) self.assertIn('Address = 10.0.0.2/32', config) - self.assertIn('DNS = 172.20.0.3', config) + self.assertIn('DNS = 10.0.0.1', config) self.assertIn(keys['public_key'], config) self.assertIn('AllowedIPs', config) @@ -418,7 +418,8 @@ class TestWireGuardConfigReads(unittest.TestCase): self._write_wg_conf(address='10.1.0.1/24') split = self.wg.get_split_tunnel_ips() self.assertIn('10.1.0.0/24', split) - self.assertIn('172.20.0.0/16', split) + # 172.20.0.0/16 is intentionally excluded — services now use WG server IP via DNAT + self.assertNotIn('172.20.0.0/16', split) self.assertNotIn('10.0.0.0/24', split) def test_get_server_config_uses_configured_port(self): diff --git a/tests/test_wireguard_vpn_routing.py b/tests/test_wireguard_vpn_routing.py index 8dda35d..657d5f6 100644 --- a/tests/test_wireguard_vpn_routing.py +++ b/tests/test_wireguard_vpn_routing.py @@ -98,9 +98,12 @@ class TestInternetForwardingRules(unittest.TestCase): class TestPeerConfigDns(unittest.TestCase): """ - Verify that peer client configs include a DNS = line pointing to the - PIC DNS container. Without DNS, the client tunnel has no internet-accessible - domain resolution even though packets are forwarded correctly. + Verify that peer client configs include a DNS = line. + + DNS is set to the WG server IP (e.g. 10.0.0.1) rather than the Docker + cell-dns container IP. ensure_dns_dnat() routes wg0:53 → cell-dns, so + peers reach CoreDNS via the WG server IP — works for both split-tunnel + (10.0.x.x in AllowedIPs) and cross-cell peers. """ def setUp(self): @@ -123,19 +126,20 @@ class TestPeerConfigDns(unittest.TestCase): # Must be a parseable IPv4 address ipaddress.IPv4Address(dns_ip) - def test_peer_config_dns_defaults_to_cell_dns_ip(self): - """When cell-dns hostname can't be resolved, falls back to 172.20.0.3.""" - with patch('wireguard_manager.socket.gethostbyname', side_effect=OSError): - keys = self.wg.generate_peer_keys('p1') - cfg = self.wg.get_peer_config('p1', '10.0.0.5', keys['private_key']) - self.assertIn('DNS = 172.20.0.3', cfg) + def test_peer_config_dns_uses_wg_server_ip(self): + """DNS in peer config is the WG server IP; ensure_dns_dnat() routes wg0:53 → cell-dns.""" + keys = self.wg.generate_peer_keys('p1') + cfg = self.wg.get_peer_config('p1', '10.0.0.5', keys['private_key']) + # Default WG server address is 10.0.0.1/24 when no wg0.conf exists + self.assertIn('DNS = 10.0.0.1', cfg) - def test_peer_config_dns_uses_resolved_hostname(self): - """When cell-dns resolves, its IP is used as the DNS server.""" - with patch('wireguard_manager.socket.gethostbyname', return_value='172.20.0.3'): + def test_peer_config_dns_fallback_to_resolve_on_error(self): + """If WG address parsing fails, _resolve_peer_dns() is used as fallback.""" + with patch.object(self.wg, '_get_configured_address', return_value='invalid'), \ + patch('wireguard_manager.socket.gethostbyname', return_value='172.20.0.9'): keys = self.wg.generate_peer_keys('p2') cfg = self.wg.get_peer_config('p2', '10.0.0.6', keys['private_key']) - self.assertIn('DNS = 172.20.0.3', cfg) + self.assertIn('DNS = 172.20.0.9', cfg) def test_resolve_peer_dns_fallback(self): """_resolve_peer_dns() always returns a string even when DNS lookup fails."""